From 6e7c67d085785dc40bd6b5156e6303a7d70c7599 Mon Sep 17 00:00:00 2001
From: JUN
Date: Mon, 9 Nov 2026 09:37:51 +0900
Subject: [PATCH] fix(update): keep bun's postinstall enabled on npm 11 and
diagnose a skipped one (#8387)
* fix(update): keep bun's postinstall enabled and diagnose a skipped one
npm 11 blocks dependency lifecycle scripts by default, so a global install
succeeded while the bundled `bun` dependency stayed a placeholder. The next
launcher run then failed with "the `bun` dependency is not installed", and on
Windows a manual reinstall under a live proxy additionally failed with EBUSY
because the running bun.exe held node_modules/bun/bin open.
Pass --allow-scripts=bun on every npm install/upgrade argv this project builds
or documents, including the Node launcher's self-update, the transactional
staging install, the install scripts, README translations and the docs site.
npm 8 through 11 all accept the flag on a global install, so it is not
version-gated. `ocx update` already stops the proxy before replacing the tree
and restarts the service afterwards, which is what releases the Windows lock.
Add a doctor "Bundled Bun runtime" section that names the skipped-postinstall
state and prints the owning manager's repair command; on Windows npm installs
it prints stop, install, restart in that order.
Closes #8361
* fix(doctor): skip the Bundled Bun heading where there is no bundled runtime
A source checkout or standalone binary has nothing to report, so doctor no longer prints an empty section. Also corrects the npm-version note: npm 11.5-11.15 warn about --allow-scripts but still run bun's postinstall.
* docs(update): date the blocked bun postinstall to npm 12 and name ignore-scripts
npm 11 runs bun's postinstall by default; npm 12 (current latest) is the major that blocks unreviewed dependency scripts. Doctor also says that --ignore-scripts or ignore-scripts=true blocks the script on any npm, since the printed repair alone does not help there.
* ci: retrigger on current dev
(cherry picked from commit ff2f5dee4a2a81a5cb1f41d0f1754fe04145180d)
---
AGENTS_INSTALL.md | 2 +-
README.md | 6 +-
bin/ocx.mjs | 2 +-
docs-site/src/components/Landing.astro | 6 +-
.../docs/fr/getting-started/for-agents.md | 2 +-
.../docs/fr/getting-started/installation.md | 4 +-
.../docs/fr/troubleshooting/windows-memory.md | 2 +-
.../docs/getting-started/for-agents.md | 2 +-
.../docs/getting-started/installation.md | 10 +-
.../docs/ja/getting-started/for-agents.md | 2 +-
.../docs/ja/getting-started/installation.md | 4 +-
.../docs/ja/troubleshooting/windows-memory.md | 2 +-
.../docs/ko/getting-started/for-agents.md | 2 +-
.../docs/ko/getting-started/installation.md | 4 +-
.../docs/ko/troubleshooting/windows-memory.md | 2 +-
.../docs/ru/getting-started/for-agents.md | 2 +-
.../docs/ru/getting-started/installation.md | 4 +-
.../docs/ru/troubleshooting/windows-memory.md | 2 +-
.../docs/tr/getting-started/for-agents.md | 2 +-
.../docs/tr/getting-started/installation.md | 4 +-
.../docs/tr/troubleshooting/windows-memory.md | 2 +-
.../docs/troubleshooting/windows-memory.md | 2 +-
.../docs/zh-cn/getting-started/for-agents.md | 2 +-
.../zh-cn/getting-started/installation.md | 4 +-
.../zh-cn/troubleshooting/windows-memory.md | 2 +-
.../docs/zh-tw/getting-started/for-agents.md | 2 +-
.../zh-tw/getting-started/installation.md | 4 +-
.../zh-tw/troubleshooting/windows-memory.md | 2 +-
readme/README.fr.md | 6 +-
readme/README.ja.md | 6 +-
readme/README.ko.md | 6 +-
readme/README.ru.md | 6 +-
readme/README.tr.md | 6 +-
readme/README.zh-CN.md | 6 +-
readme/README.zh-TW.md | 6 +-
readme/i18n-manifest.json | 14 +--
scripts/install.ps1 | 8 +-
scripts/install.sh | 8 +-
scripts/test-layout/layout.json | 4 +-
src/cli/bundled-bun-diagnostic.ts | 91 ++++++++++++++
src/cli/doctor.ts | 11 ++
src/lib/bun-runtime-preflight.ts | 2 +-
src/update/index.ts | 8 +-
structure/ops/desktop-supervision.md | 9 ++
structure/ops/service-and-sidecars.md | 2 +-
structure/runtime.md | 2 +-
tests/ci-workflows/install-scripts.test.ts | 10 +-
.../codex-shim-runtime-preflight.test.ts | 3 +-
tests/fixtures/test-layout-expected.json | 3 +-
tests/lib/bun-runtime-preflight.test.ts | 4 +-
tests/service/doctor-bun-install.test.ts | 117 ++++++++++++++++++
tests/update/update-job.test.ts | 3 +-
tests/update/update-npm-allow-scripts.test.ts | 48 +++++++
53 files changed, 386 insertions(+), 89 deletions(-)
create mode 100644 src/cli/bundled-bun-diagnostic.ts
create mode 100644 tests/service/doctor-bun-install.test.ts
create mode 100644 tests/update/update-npm-allow-scripts.test.ts
diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md
index c4a80069fd7..0bab328853d 100644
--- a/AGENTS_INSTALL.md
+++ b/AGENTS_INSTALL.md
@@ -92,7 +92,7 @@ A host that has both a Task Scheduler entry and a WinSW service is in a conflict
### The CLI
-`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the
+`npm install -g --allow-scripts=bun @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the
Bun runtime it needs is bundled inside that package. There is no separate runtime to install and
no WSL layer on Windows.
diff --git a/README.md b/README.md
index db98afcaf0a..263079fb129 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ and connect OpenCode, Pi, Qoder, WorkBuddy, Kilo, Cline, T3 Code and more throug
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -94,7 +94,7 @@ providers with Codex, Claude Code, Claude Desktop, Grok Build and the tools list
### Personal install (CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
ocx start # proxy + dashboard on localhost:10100
```
@@ -253,7 +253,7 @@ they reach the npm package.
For agents
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # or `ocx service`
ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex
```
diff --git a/bin/ocx.mjs b/bin/ocx.mjs
index 72eff87dfcf..ef5b11ad33d 100755
--- a/bin/ocx.mjs
+++ b/bin/ocx.mjs
@@ -331,7 +331,7 @@ async function runPackageManagerSelfUpdate(manager, child) {
const latestInvocation = managerInvocation(["view", `${PKG}@${selector}`, "version"]);
const installArgs = manager === "pnpm"
? ["add", "-g", "--allow-build=bun", `${PKG}@${selector}`]
- : ["install", "-g", `${PKG}@${selector}`];
+ : ["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`];
const installInvocation = managerInvocation(installArgs);
if (!latestInvocation || !installInvocation) {
console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro
index 99a3f92abb8..c7f68222199 100644
--- a/docs-site/src/components/Landing.astro
+++ b/docs-site/src/components/Landing.astro
@@ -165,14 +165,14 @@ const providers: { label: string; path?: string }[] = [
];
const quickstartMini = [
- { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
+ { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
{ cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') },
{ cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") },
{ cmd: 'ocx claude', note: t('In another terminal: launch Claude Code through ocx', '다른 터미널에서: ocx를 거쳐 Claude Code 실행', '在另一个终端中:通过 ocx 启动 Claude Code', 'В другом терминале: запуск Claude Code через ocx', '別のターミナルで: ocx 経由で Claude Code を起動', '在另一個終端機中:透過 ocx 啟動 Claude Code', 'Başka bir terminalde: Claude Code’u ocx üzerinden başlatın') },
];
const quickstart = [
- { cmd: 'npm install -g @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
+ { cmd: 'npm install -g --allow-scripts=bun @bitkyc08/opencodex', note: t('Install', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum') },
{ cmd: 'ocx init', note: t('Add a provider and connect Codex', '프로바이더 추가 후 Codex 연결', '添加提供商并连接 Codex', 'Добавьте провайдера и подключите Codex', 'プロバイダーを追加して Codex を接続', '新增供應商並連接 Codex', 'Sağlayıcı ekleyip Codex’i bağlayın') },
{ cmd: 'ocx start', note: t('Start the proxy', '프록시 시작', '启动代理', 'Запуск прокси', 'プロキシを起動', '啟動代理', "Proxy'yi başlatın") },
{ cmd: 'codex "Write a hello world in Rust"', note: t('Codex now routes through ocx', 'Codex가 ocx를 경유', 'Codex 现在经由 ocx 路由', 'Codex теперь работает через ocx', 'Codex は ocx 経由に', 'Codex 現在經由 ocx 路由', 'Codex artık ocx üzerinden yönlendirilir') },
@@ -344,7 +344,7 @@ const docsMap = [
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex est une passerelle locale légère entre vos outils de programmation e
### Installation personnelle (CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+ ; le runtime Bun est inclus automatiquement
ocx start # proxy + tableau de bord sur localhost:10100
```
@@ -250,7 +250,7 @@ leur arrivée dans le paquet npm.
Pour les agents
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # ou `ocx service`
ocx init # configuration interactive : écrit ~/.opencodex/config.json et relie Codex
```
diff --git a/readme/README.ja.md b/readme/README.ja.md
index 734f849f17d..ba4b91408b5 100644
--- a/readme/README.ja.md
+++ b/readme/README.ja.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex は、コーディングツールとモデルプロバイダーをつ
### 個人向けインストール(CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18 以上。Bun ランタイムは自動で同梱されます
ocx start # プロキシとダッシュボードが localhost:10100 で起動
```
@@ -246,7 +246,7 @@ bun run src/cli/index.ts start
エージェント向け
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # または `ocx service`
ocx init # 対話式セットアップ: ~/.opencodex/config.json を書き、Codex を接続します
```
diff --git a/readme/README.ko.md b/readme/README.ko.md
index 6586139d130..b631de84ad8 100644
--- a/readme/README.ko.md
+++ b/readme/README.ko.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex는 코딩 도구와 모델 프로바이더를 연결하는 가벼운
### 개인 설치 (CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun 런타임은 자동으로 번들됩니다
ocx start # 프록시 + 대시보드: localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
에이전트용
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # 또는 `ocx service`
ocx init # 대화형 설정: ~/.opencodex/config.json을 쓰고 Codex를 연결합니다
```
diff --git a/readme/README.ru.md b/readme/README.ru.md
index 0b1d8184243..c1ac691fe02 100644
--- a/readme/README.ru.md
+++ b/readme/README.ru.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex — лёгкий локальный шлюз между вашими
### Личная установка (CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; рантайм Bun подключается автоматически
ocx start # прокси + панель управления на localhost:10100
```
@@ -252,7 +252,7 @@ bun run src/cli/index.ts start
Для агентов
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # или `ocx service`
ocx init # интерактивная настройка: пишет ~/.opencodex/config.json и подключает Codex
```
diff --git a/readme/README.tr.md b/readme/README.tr.md
index 8bb58c01bcd..216c2d0f570 100644
--- a/readme/README.tr.md
+++ b/readme/README.tr.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex, kodlama araçlarınızla model sağlayıcılarınız arasında çalı
### Kişisel kurulum (CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; Bun çalışma zamanı otomatik olarak paketlenir
ocx start # proxy + kontrol paneli, localhost:10100
```
@@ -248,7 +248,7 @@ iyileştirmeleri ve henüz yayımlanmamış düzeltmeler npm paketine ulaşmadan
Ajanlar için
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # ya da `ocx service`
ocx init # etkileşimli kurulum: ~/.opencodex/config.json yazar ve Codex'i bağlar
```
diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md
index 1c0a6ef79b6..13d4c9317a1 100644
--- a/readme/README.zh-CN.md
+++ b/readme/README.zh-CN.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex 是连接编程工具与模型提供商的轻量级本地网关。它
### 个人安装(CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 运行时会自动捆绑
ocx start # 代理 + 仪表板:localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
面向 agent
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # 或 `ocx service`
ocx init # 交互式设置:写入 ~/.opencodex/config.json 并接入 Codex
```
diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md
index b48d492e0d5..7843c589bf6 100644
--- a/readme/README.zh-TW.md
+++ b/readme/README.zh-TW.md
@@ -15,7 +15,7 @@
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start
```
@@ -88,7 +88,7 @@ opencodex 是連接程式開發工具與模型供應商的輕量級本機閘道
### 個人安裝(CLI)
```bash
-npm install -g @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包
+npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+;Bun 執行環境會自動打包
ocx start # 代理 + 儀表板位於 localhost:10100
```
@@ -241,7 +241,7 @@ bun run src/cli/index.ts start
給 agent 使用
```bash
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
ocx start # 或 `ocx service`
ocx init # 互動式設定:寫入 ~/.opencodex/config.json 並接上 Codex
```
diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json
index 7fc9221d996..cb937aa02f5 100644
--- a/readme/i18n-manifest.json
+++ b/readme/i18n-manifest.json
@@ -6,43 +6,43 @@
"file": "readme/README.fr.md",
"label": "Français",
"docsPath": "fr",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"ko": {
"file": "readme/README.ko.md",
"label": "한국어",
"docsPath": "ko",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"zh-CN": {
"file": "readme/README.zh-CN.md",
"label": "简体中文",
"docsPath": "zh-cn",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"zh-TW": {
"file": "readme/README.zh-TW.md",
"label": "繁體中文",
"docsPath": "zh-tw",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"ru": {
"file": "readme/README.ru.md",
"label": "Русский",
"docsPath": "ru",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"ja": {
"file": "readme/README.ja.md",
"label": "日本語",
"docsPath": "ja",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
},
"tr": {
"file": "readme/README.tr.md",
"label": "Türkçe",
"docsPath": "tr",
- "sourceSha256": "317fdcc0b4e903303817e820afd6fd620dccbcee9ab0caf9e64fd87e9a966682"
+ "sourceSha256": "941205d861dcfa525b40c8a70ed89ede49b9591d97b30e695aaff066f70f379d"
}
}
}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 1da608a5b99..0b3ab9c3fd5 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -22,15 +22,15 @@ if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Host "Using Node v$nodeVersion"
-# Install opencodex globally
-# If npm reports "install scripts blocked" for bun, rerun as:
-# npm install -g --allow-scripts=bun @bitkyc08/opencodex
+# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and
+# bun's postinstall is what unpacks the bundled runtime; without this approval the install
+# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs.
# (use an elevated PowerShell if the original install was elevated)
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npm) {
$npm = Get-Command npm -ErrorAction Stop
}
-& $npm.Source install -g @bitkyc08/opencodex
+& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex
if ($LASTEXITCODE -ne 0) {
Write-Error "npm install failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
diff --git a/scripts/install.sh b/scripts/install.sh
index 6a25db452e9..54715adc9c7 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -21,11 +21,11 @@ fi
echo "Using Node $(node --version)"
-# Install opencodex globally
-# If npm reports "install scripts blocked" for bun, rerun as:
-# npm install -g --allow-scripts=bun @bitkyc08/opencodex
+# Install opencodex globally. npm 12 blocks dependency lifecycle scripts by default, and
+# bun's postinstall is what unpacks the bundled runtime; without this approval the install
+# succeeds but `ocx` cannot start (#8361). npm 8 through 11 accept the flag on global installs.
# (keep sudo if the original install used sudo)
-npm install -g @bitkyc08/opencodex
+npm install -g --allow-scripts=bun @bitkyc08/opencodex
if ! command -v ocx &>/dev/null; then
NPM_BIN="$(npm bin -g 2>/dev/null || printf "%s/bin" "$(npm prefix -g)")"
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index aed2989c321..867e3ee78d5 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -925,7 +925,7 @@
"docs-readme-memory-inventory.test.ts": "ci-workflows",
"docs-readme-translation-parity.test.ts": "ci-workflows",
"docs-remote-hub-claims.test.ts": "ci-workflows",
- "doctor-codex-envkey-readiness.test.ts": "service",
+ "doctor-codex-envkey-readiness.test.ts": "service", "doctor-bun-install.test.ts": "service",
"doctor-oauth.test.ts": "service",
"doctor-provider-apikey.test.ts": "service",
"doctor-service-memory-contract.test.ts": "service",
@@ -1839,7 +1839,7 @@
"update-async-routes.test.ts": "server",
"update-badge.test.ts": "update", "update-bounded-json.test.ts": "update", "update-checkpoint-codec.test.ts": "update", "update-contracts.test.ts": "update", "update-candidate-id.test.ts": "update", "update-external-metadata.test.ts": "update", "update-minisign.test.ts": "update", "update-root-snapshot.test.ts": "update", "update-signed-supply.test.ts": "update", "update-signed-version.test.ts": "update", "update-trust-root.test.ts": "update", "update-bun-ownership-lease.test.ts": "update",
"update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update",
- "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update",
+ "update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update", "update-npm-allow-scripts.test.ts": "update",
"update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update",
"update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update", "update-mise-launcher-target.test.ts": "update",
"update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update",
diff --git a/src/cli/bundled-bun-diagnostic.ts b/src/cli/bundled-bun-diagnostic.ts
new file mode 100644
index 00000000000..d9d8535a83c
--- /dev/null
+++ b/src/cli/bundled-bun-diagnostic.ts
@@ -0,0 +1,91 @@
+/**
+ * `ocx doctor` check for a package install whose bundled Bun dependency never finished
+ * installing (#8361).
+ *
+ * npm 12 blocks dependency lifecycle scripts by default. The `bun` npm package downloads nothing
+ * in its tarball: its postinstall copies the selected `@oven/bun-*` executable into `bin/`. An
+ * install that skipped that script succeeds and leaves a ~450-byte placeholder, so the launcher
+ * fails later with "the `bun` dependency is not installed". Doctor is where an operator looks
+ * after that failure, so it names the state and the exact command that repairs it.
+ *
+ * The check is observe-only and never fails the doctor run: a leftover PATH Bun or an explicit
+ * `OPENCODEX_BUN_PATH` can still be serving requests, which makes this a degraded install rather
+ * than an unusable surface.
+ */
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import * as bunRuntime from "../lib/bun-runtime";
+import * as installDetection from "../update/install-detection.mjs";
+
+const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+export const BUNDLED_BUN_MISSING_DETAIL = "the `bun` dependency is not installed";
+export const NPM_BUN_REPAIR_COMMAND = "npm install -g --allow-scripts=bun @bitkyc08/opencodex";
+
+type PackageInstaller = "npm" | "pnpm" | "bun" | "mise";
+
+export type BundledBunDiagnostic =
+ | { status: "ready"; path: string }
+ | { status: "not-applicable" }
+ | { status: "missing"; installer: PackageInstaller; lines: string[] };
+
+export interface BundledBunDiagnosticDeps {
+ platform?: NodeJS.Platform;
+ bundledPath?: () => string | null;
+ detectOwnership?: () => { installer: string; owner?: { tool: string } | null };
+}
+
+/** The command that reinstalls this package while allowing bun's postinstall. */
+export function bundledBunRepairCommand(installer: PackageInstaller, tool?: string | null): string | null {
+ if (installer === "npm") return NPM_BUN_REPAIR_COMMAND;
+ if (installer === "pnpm") return "pnpm add -g --allow-build=bun @bitkyc08/opencodex";
+ if (installer === "bun") return "bun add -g @bitkyc08/opencodex";
+ return tool ? `mise upgrade ${tool}` : null;
+}
+
+export function collectBundledBunDiagnostic(deps: BundledBunDiagnosticDeps = {}): BundledBunDiagnostic {
+ const bundled = (deps.bundledPath ?? bunRuntime.bundledBunPath)();
+ if (bundled) return { status: "ready", path: bundled };
+
+ const ownership = (deps.detectOwnership
+ ?? (() => installDetection.detectInstallOwnershipFromPath(PACKAGE_ROOT)))();
+ const installer = ownership.installer;
+ // Only the mise verdict carries an owner; the others have no tool to name.
+ const ownerTool = "owner" in ownership ? ownership.owner?.tool ?? null : null;
+ // A source checkout has no bundled dependency by design, and a standalone binary carries its
+ // own runtime; neither is a skipped-postinstall symptom.
+ if (installer !== "npm" && installer !== "pnpm" && installer !== "bun" && installer !== "mise") {
+ return { status: "not-applicable" };
+ }
+
+ const repair = bundledBunRepairCommand(installer, ownerTool);
+ const windows = (deps.platform ?? process.platform) === "win32";
+ const lines = [
+ ` !! ${BUNDLED_BUN_MISSING_DETAIL} (its postinstall was skipped or the dependency is incomplete)`,
+ " Allow bun's own install script; npm 12 blocks lifecycle scripts by default,",
+ " and --ignore-scripts or ignore-scripts=true in an npmrc blocks it on any npm",
+ ];
+ if (!repair) {
+ lines.push(" This mise-owned installation has no verified tool name; upgrade it through mise.");
+ return { status: "missing", installer, lines };
+ }
+ if (windows && installer === "npm") {
+ // A running bun.exe holds node_modules/bun/bin open, so npm fails with EBUSY. The instruction
+ // has to stop first; `ocx update` does this itself, but a manual reinstall does not.
+ lines.push(" Stop the proxy first so Windows releases node_modules/bun/bin, then run:");
+ lines.push(" ocx stop");
+ lines.push(` ${repair}`);
+ lines.push(" Restart afterwards: ocx service restart (or ocx start when unmanaged)");
+ } else {
+ lines.push(" Fix: reinstall with the package manager that owns this install:");
+ lines.push(` ${repair}`);
+ }
+ return { status: "missing", installer, lines };
+}
+
+/** Render the doctor "Bundled Bun runtime" section lines. */
+export function formatBundledBunDiagnosticLines(diagnostic: BundledBunDiagnostic): string[] {
+ if (diagnostic.status === "ready") return [` ok ready: ${diagnostic.path}`];
+ if (diagnostic.status === "not-applicable") return [];
+ return diagnostic.lines;
+}
diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts
index 3dea81c1020..04421d3ac65 100644
--- a/src/cli/doctor.ts
+++ b/src/cli/doctor.ts
@@ -8,6 +8,7 @@
* networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*.
*/
import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics";
+import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "./bundled-bun-diagnostic";
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
@@ -1607,6 +1608,16 @@ export async function runDoctor(args: string[] = []): Promise {
}
}
+ // The bundled dependency is the install's own runtime, distinct from whatever Bun is running
+ // doctor or serving the proxy. A skipped postinstall (#8361) is invisible here otherwise.
+ // A source checkout or standalone binary has no bundled `bun` dependency, so the
+ // section has nothing to say there and prints no heading.
+ const bundledBunLines = formatBundledBunDiagnosticLines(collectBundledBunDiagnostic());
+ if (bundledBunLines.length > 0) {
+ console.log("\nBundled Bun runtime");
+ for (const line of bundledBunLines) console.log(line);
+ }
+
console.log("\nWHAM reachability");
const probe = await probeWham();
const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
diff --git a/src/lib/bun-runtime-preflight.ts b/src/lib/bun-runtime-preflight.ts
index 3f3e7f6a68b..eb71a7551d4 100644
--- a/src/lib/bun-runtime-preflight.ts
+++ b/src/lib/bun-runtime-preflight.ts
@@ -9,7 +9,7 @@ export const RUNTIME_PREFLIGHT_TIMEOUT_MS = 5_000;
export const RUNTIME_PREFLIGHT_GUIDANCE = "The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. "
+ "Windows application policy may deny writes from this executable location. "
+ "Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, "
- + "or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected.";
+ + "or reinstall opencodex with npm install -g --allow-scripts=bun @bitkyc08/opencodex, then retry. No alternate runtime was selected.";
export class RuntimePreflightError extends Error {
readonly code = "OCX_RUNTIME_PREFLIGHT_FAILED";
diff --git a/src/update/index.ts b/src/update/index.ts
index 2b380ea3ed0..68b5b101491 100644
--- a/src/update/index.ts
+++ b/src/update/index.ts
@@ -364,7 +364,13 @@ export function updateCommand(installer: Installer, tag: Channel, resolvedVersio
return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] };
}
const bin = "npm";
- const args = ["install", "-g", `${PKG}@${target}`];
+ // npm 12 blocks dependency lifecycle scripts by default, and bun's postinstall is what
+ // copies the selected @oven/bun-* executable into bun/bin. Without this narrow approval
+ // the install succeeds but leaves bun's un-downloaded placeholder, so the next `ocx`
+ // cannot start (#8361). npm 6 through 11 run bun's postinstall anyway and install with the
+ // flag on global installs, so it is unconditional rather than version-gated; some npm 11
+ // releases print an "Unknown cli config" warning for it and still install.
+ const args = ["install", "-g", "--allow-scripts=bun", `${PKG}@${target}`];
return { bin, args };
}
diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md
index 1f4c2d9f216..fb27b68cc17 100644
--- a/structure/ops/desktop-supervision.md
+++ b/structure/ops/desktop-supervision.md
@@ -127,6 +127,15 @@ formatting is the only allowlisted work between that authorizer and process crea
`tests/update/update-home-launcher.test.ts` and `tests/update/update-home-recovery.test.ts`
exercise refusal when preparation changes the home observation, plus stable-home starts.
+The npm install argv that `updateCommand()` (and the Node launcher's own npm self-update) builds
+always carries `--allow-scripts=bun`: npm 12 blocks dependency lifecycle scripts by default, and
+bun's postinstall is what unpacks the bundled runtime, so a bare global install would leave the
+next `ocx` unable to start (#8361). npm 8 through 11 accept the flag on global installs, so it is
+not version-gated. The npm package replacement stages beside the live tree and stops the proxy
+before the swap, which is also what releases `node_modules/bun/bin` on Windows; a manual
+`npm install -g` under a live proxy still fails with `EBUSY`, so doctor prints the stop, install
+and restart sequence for npm-owned installs instead of attempting it.
+
The bypass ledger classifies these guards as **early warning**, not enforcement:
| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion |
diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md
index 2bb82bb72e1..70349480a36 100644
--- a/structure/ops/service-and-sidecars.md
+++ b/structure/ops/service-and-sidecars.md
@@ -100,7 +100,7 @@ The frozen selection feeds scheduler rendering, the WinSW entry and the install-
The fresh scheduler path captures root absence and freezes the runtime at entry, stages and registers its definition, removes staging and claims config ownership, then probes with `rootWasAbsent: false` immediately before `prepare()`; a refused probe rolls back the new registration before service-manager cleanup or asset/state publication. Healthy or disabled shims do not probe; refused automatic restore defers with guidance and startup continues. Non-Windows admission performs no spawn or filesystem work, selection stays pre-dotenv and `cliEntry` stays I/O-free. That cleanup (`prepareServiceInstall`) ends every recorded, conflicting and requested manager, then — Windows only — issues the same best-effort launcher/wrapper termination `ocx stop` performs, scoped to this install's canonical script and launcher tokens (an `schtasks /end` survivor can respawn the proxy mid-install; this layer guarantees neither that the wrapper actually exited nor that the new instance comes up), then stops the tracked standalone listener; a refused or unverifiable manager stop aborts before that cleanup runs.
An absent root reaching admission is created with an exclusive non-recursive mode-0700 mkdir under an existing parent and left empty for later ownership claiming; the preflight never deletes the config root (refusal, concurrent creation, contents and replacements are all preserved). Existing roots must be real directories, not files or symlinks/junctions, and probe cleanup is non-recursive.
-A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added.
+A standalone selection is admitted in-process only when its path equals `process.execPath` exactly (standalone is executable packaging, not Desktop ownership); that path has neither child isolation nor a timeout, so synchronous filesystem operations may block, and other standalone selections refuse. Recovery is an operator-selected trusted `OPENCODEX_BUN_PATH` before launching ocx, or an `npm install -g --allow-scripts=bun @bitkyc08/opencodex` reinstall followed by retry; no runtime discovery or probe memo is added. The flag is part of the contract, not advice: npm 12 blocks dependency lifecycle scripts by default and bun's postinstall is what unpacks the binary, while npm 8 through 11 accept the flag on a global install (#8361).
## Windows npm tray update badge
diff --git a/structure/runtime.md b/structure/runtime.md
index 1aa77595d12..749a67f4baa 100644
--- a/structure/runtime.md
+++ b/structure/runtime.md
@@ -121,7 +121,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour
| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. |
| `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. |
| `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. |
-| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. |
+| `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` through the byte-capped direct-local transport when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. `src/cli/bundled-bun-diagnostic.ts` is the separate "Bundled Bun runtime" section: when `bundledBunPath()` finds no real binary it asks the package-layout detector who owns the install and prints that manager's repair command, because a skipped npm postinstall (npm 12 blocks lifecycle scripts by default) otherwise surfaces only later as the launcher's "the `bun` dependency is not installed" (#8361). It is observe-only and records no doctor failure — a PATH Bun or `OPENCODEX_BUN_PATH` can still serve — and on Windows the npm guidance stops the proxy before reinstalling, since a live `bun.exe` holds `node_modules/bun/bin` open. |
| `src/router.ts` | Provider/model selection before adapter dispatch. Policy execution and ordinary management dry-run share effective-provider capability evidence; unresolved, missing, and disabled providers are excluded before scoring. |
| `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes. `src/providers/routed-config.ts` owns request-time provider/registry resolution and the discarded-base-URL warning memo; key selection imports that leaf instead of the router, while `src/router.ts` retains the resolver and warning-reconciliation compatibility exports. The leaf preserves GCP service-account credential markers verbatim until the Vertex adapter resolves the selected account per request. |
| `src/types.ts` | Shared config, parsed request, adapter, and event types. |
diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts
index 4d3674c44f6..dd3841279c9 100644
--- a/tests/ci-workflows/install-scripts.test.ts
+++ b/tests/ci-workflows/install-scripts.test.ts
@@ -142,7 +142,9 @@ describe("install scripts", () => {
const script = await readText("scripts/install.sh");
expect(script).toContain("Node.js 18+ is required");
- expect(script).toContain("npm install -g @bitkyc08/opencodex");
+ // #8361: npm 12 blocks bun's postinstall by default, so a bare install produces a
+ // package whose bundled runtime never unpacked. Both installers must permit that one script.
+ expect(script).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
expect(script).toContain("command -v ocx");
expect(script).toContain("ocx help");
expect(script).not.toContain("bun install -g @bitkyc08/opencodex");
@@ -153,7 +155,7 @@ describe("install scripts", () => {
const script = await readText("scripts/install.ps1");
expect(script).toContain("Node.js 18+ is required");
- expect(script).toContain("& $npm.Source install -g @bitkyc08/opencodex");
+ expect(script).toContain("& $npm.Source install -g --allow-scripts=bun @bitkyc08/opencodex");
expect(script).toContain("$LASTEXITCODE");
expect(script).toContain("Get-Command ocx.cmd");
expect(script).toContain("Get-Command ocx");
@@ -219,7 +221,9 @@ exit 0
expect(launcher).toContain('process.argv[2] === "update"');
expect(launcher).toContain('const selector = child?.version ?? tag;');
- expect(launcher).toContain('["install", "-g", `${PKG}@${selector}`]');
+ // #8361: the launcher's own npm reinstall must allow bun's postinstall for the same
+ // reason the shipped installers do; a bare install leaves the runtime un-unpacked.
+ expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]');
expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${selector}`]');
expect(launcher).toContain('const tag = updateTag(current);');
expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"');
diff --git a/tests/codex-integration/codex-shim-runtime-preflight.test.ts b/tests/codex-integration/codex-shim-runtime-preflight.test.ts
index c4ccf7b0cf2..bca8ac902d0 100644
--- a/tests/codex-integration/codex-shim-runtime-preflight.test.ts
+++ b/tests/codex-integration/codex-shim-runtime-preflight.test.ts
@@ -61,7 +61,8 @@ describe("Windows shim selected-runtime preflight", () => {
expect(maybeAutoRestoreCodexShim("start", ["start"], { env: {}, warn: message => warnings.push(message), restore: () => result,
readConfig: () => ({ config: getDefaultConfig(), source: "default", error: null }),
})).toBeUndefined();
- expect(warnings).toEqual([expect.stringContaining("npm install -g @bitkyc08/opencodex")]);
+ // #8361: the repair hint must approve bun's postinstall; npm 12 skips it by default.
+ expect(warnings).toEqual([expect.stringContaining("npm install -g --allow-scripts=bun @bitkyc08/opencodex")]);
}));
}
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 58e03d7dd46..b80bc33ec35 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -855,6 +855,7 @@
"docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows",
"docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows",
"docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service",
+ "doctor-bun-install.test.ts": "service",
"doctor-oauth.test.ts": "service", "doctor-provider-apikey.test.ts": "service",
"doctor-service-memory-contract.test.ts": "service", "doctor.test.ts": "codex-integration",
"download-cap-default.test.ts": "images", "download-connect-deadline-default.test.ts": "images",
@@ -1452,7 +1453,7 @@
"update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update",
"update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update",
"update-standalone.test.ts": "update",
- "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update",
+ "update-worker-launch.test.ts": "update", "update-notify.test.ts": "update", "update-npm-allow-scripts.test.ts": "update",
"update-orchestrated-session.test.ts": "update", "update-orchestrated-entry.test.ts": "update", "update-orchestrated-apply.test.ts": "update", "update-orchestrated-arguments.test.ts": "update", "update-orchestrated-plan.test.ts": "update", "update-sync-mutex.test.ts": "update", "update-orchestrated-identity.test.ts": "update", "update-orchestrated-token.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-npm-timeout.test.ts": "update", "update-npm-supervisor-exit.test.ts": "update",
"update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update",
"update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update",
diff --git a/tests/lib/bun-runtime-preflight.test.ts b/tests/lib/bun-runtime-preflight.test.ts
index 0c7dea985ff..adea703eb0a 100644
--- a/tests/lib/bun-runtime-preflight.test.ts
+++ b/tests/lib/bun-runtime-preflight.test.ts
@@ -23,7 +23,9 @@ function refusal(run: () => void, reason: RuntimePreflightReason): void {
expect(error).toBeInstanceOf(RuntimePreflightError);
expect(error).toMatchObject({ code: "OCX_RUNTIME_PREFLIGHT_FAILED", reason });
expect((error as Error).message).toContain("OPENCODEX_BUN_PATH");
- expect((error as Error).message).toContain("npm install -g @bitkyc08/opencodex");
+ // #8361: the repair command must permit bun's postinstall, or npm 12 leaves the
+ // bundled runtime as its placeholder and the retry fails the same way.
+ expect((error as Error).message).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
}
}
function child(result: Partial>): typeof spawnSync {
diff --git a/tests/service/doctor-bun-install.test.ts b/tests/service/doctor-bun-install.test.ts
new file mode 100644
index 00000000000..0b054ef8bd0
--- /dev/null
+++ b/tests/service/doctor-bun-install.test.ts
@@ -0,0 +1,117 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
+import * as proxyLiveness from "../../src/server/proxy-liveness";
+import * as bunRuntime from "../../src/lib/bun-runtime";
+import * as installDetection from "../../src/update/install-detection.mjs";
+import { getDefaultConfig } from "../../src/config";
+import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runDoctor } from "../../src/cli/doctor";
+import { collectBundledBunDiagnostic, formatBundledBunDiagnosticLines } from "../../src/cli/bundled-bun-diagnostic";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+import { STORE_BUDGET_MS } from "../helpers/test-budget";
+
+/**
+ * Issue #8361: npm 12 blocks lifecycle scripts by default, so `npm install -g` leaves the
+ * bundled `bun` dependency as its un-downloaded placeholder. The launcher then reports
+ * "the `bun` dependency is not installed" and only a reinstall that permits bun's own
+ * postinstall repairs it. Doctor must name that state and the exact command.
+ *
+ * The bundled runtime and package-manager detection are faked at their module seams so the
+ * case exercises `runDoctor`'s real call site rather than a helper in isolation.
+ */
+describe("doctor bundled Bun install guidance (#8361)", () => {
+ let home: string;
+ let codexHome: string;
+ let previousHome: string | undefined;
+ let previousCodexHome: string | undefined;
+ let previousExitCode: typeof process.exitCode;
+ let logged: string[];
+ const restore: Array<() => void> = [];
+
+ beforeEach(() => {
+ home = mkdtempSync(join(tmpdir(), "ocx-doctor-bun-install-"));
+ codexHome = join(home, "codex");
+ mkdirSync(codexHome, { recursive: true });
+ previousHome = process.env.OPENCODEX_HOME;
+ previousCodexHome = process.env.CODEX_HOME;
+ previousExitCode = process.exitCode;
+ process.env.OPENCODEX_HOME = home;
+ process.env.CODEX_HOME = codexHome;
+ writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, codexAutoStart: false }));
+ logged = [];
+ const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(" ")); });
+ restore.push(() => log.mockRestore());
+ // Doctor's other sections probe upstream health; this fixture must stay offline.
+ const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 }));
+ restore.push(() => fetch.mockRestore());
+ const live = spyOn(proxyLiveness, "findLiveProxy").mockResolvedValue({ pid: null, port: 9, hostname: "127.0.0.1", source: "config" });
+ restore.push(() => live.mockRestore());
+ });
+
+ afterEach(() => {
+ for (const cleanup of restore.splice(0).reverse()) cleanup();
+ process.exitCode = previousExitCode;
+ if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousHome;
+ if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = previousCodexHome;
+ if (existsSync(home)) removeTreeWithRetry(home);
+ });
+
+ test("an npm install whose Bun postinstall was skipped prints the exact repair command", async () => {
+ // A skipped postinstall leaves no real binary (bundledBunPath returns null for the
+ // placeholder stub). The package layout still identifies npm as the owner.
+ const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null);
+ restore.push(() => bundled.mockRestore());
+ const detection = spyOn(installDetection, "detectInstallOwnershipFromPath")
+ .mockReturnValue({ installer: "npm" });
+ restore.push(() => detection.mockRestore());
+
+ await runDoctor([]);
+ const report = logged.join("\n");
+
+ expect(report).toContain("the `bun` dependency is not installed");
+ expect(report).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
+ }, STORE_BUDGET_MS);
+
+ test("a ready bundled runtime does not suggest reinstalling", async () => {
+ const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue("/opt/ocx/node_modules/bun/bin/bun");
+ restore.push(() => bundled.mockRestore());
+
+ await runDoctor([]);
+ const report = logged.join("\n");
+
+ expect(report).toContain("Bundled Bun runtime");
+ expect(report).not.toContain("the `bun` dependency is not installed");
+ expect(report).not.toContain("npm install -g --allow-scripts=bun");
+ }, STORE_BUDGET_MS);
+
+ test("a source checkout prints no empty Bundled Bun runtime heading", async () => {
+ const bundled = spyOn(bunRuntime, "bundledBunPath").mockReturnValue(null);
+ restore.push(() => bundled.mockRestore());
+ const detection = spyOn(installDetection, "detectInstallOwnershipFromPath")
+ .mockReturnValue({ installer: "source" });
+ restore.push(() => detection.mockRestore());
+
+ await runDoctor([]);
+
+ expect(logged.join("\n")).not.toContain("Bundled Bun runtime");
+ }, STORE_BUDGET_MS);
+
+ test("a Windows npm install stops the proxy before reinstalling, since a live bun.exe holds bin", () => {
+ // Windows fails the manual reinstall with EBUSY while the service's bun.exe is running.
+ // `ocx update` handles this itself; the printed instruction has to match what it does.
+ const diagnostic = collectBundledBunDiagnostic({
+ platform: "win32",
+ bundledPath: () => null,
+ detectOwnership: () => ({ installer: "npm" }),
+ });
+ const lines = formatBundledBunDiagnosticLines(diagnostic).join("\n");
+
+ expect(lines).toContain("the `bun` dependency is not installed");
+ expect(lines.indexOf("ocx stop")).toBeLessThan(lines.indexOf("npm install -g --allow-scripts=bun"));
+ expect(lines).toContain("npm install -g --allow-scripts=bun @bitkyc08/opencodex");
+ expect(lines).toContain("ocx service restart");
+ });
+});
diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts
index fe4780a2aa4..962a42405f7 100644
--- a/tests/update/update-job.test.ts
+++ b/tests/update/update-job.test.ts
@@ -1836,7 +1836,8 @@ describe("GUI update execution decisions", () => {
describe("immutable update target (WP160)", () => {
test("a resolved version pins the install target instead of the movable tag", () => {
expect(updateCommand("bun", "latest", "2.7.24").args).toEqual(["add", "-g", "@bitkyc08/opencodex@2.7.24"]);
- expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "@bitkyc08/opencodex@2.7.24"]);
+ // npm 12 blocks dependency lifecycle scripts by default; see update-npm-allow-scripts.test.ts.
+ expect(updateCommand("npm", "latest", "2.7.24").args).toEqual(["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"]);
expect(updateCommandStr("bun", "latest", "2.7.24")).toContain("@bitkyc08/opencodex@2.7.24");
// Unknown version falls back to the tag (best-effort lane).
expect(updateCommand("bun", "latest").args).toEqual(["add", "-g", "@bitkyc08/opencodex@latest"]);
diff --git a/tests/update/update-npm-allow-scripts.test.ts b/tests/update/update-npm-allow-scripts.test.ts
new file mode 100644
index 00000000000..11d577234a1
--- /dev/null
+++ b/tests/update/update-npm-allow-scripts.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { updateCommand, updateCommandStr } from "../../src/update/index";
+import { repoPath } from "../helpers/repo-root";
+
+/**
+ * Issue #8361: npm 12 blocks dependency lifecycle scripts by default, so an `ocx update` that
+ * ran a bare `npm install -g` removed the old files and installed a package whose bundled `bun`
+ * dependency never unpacked. The next `ocx` then failed with "the `bun` dependency is not
+ * installed". Every path that builds an npm install argument must approve exactly that one script.
+ *
+ * The flag is not conditional on the npm major: npm 8 through 11 all install with it on a global
+ * install (some npm 11 releases warn that it is unknown to the CLI config, then install).
+ * A version gate would add a failure mode that depends on which npm happened to be first on PATH.
+ */
+describe("npm update allows bun's postinstall (#8361)", () => {
+ test("the update command passes --allow-scripts=bun before the package spec", () => {
+ expect(updateCommand("npm", "latest", "2.7.24")).toEqual({
+ bin: "npm",
+ args: ["install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@2.7.24"],
+ });
+ // The tag fallback (registry resolution unavailable) keeps the same approval.
+ expect(updateCommand("npm", "preview", null).args).toEqual([
+ "install", "-g", "--allow-scripts=bun", "@bitkyc08/opencodex@preview",
+ ]);
+ expect(updateCommandStr("npm", "latest", "2.7.24"))
+ .toBe("npm install -g --allow-scripts=bun @bitkyc08/opencodex@2.7.24");
+ });
+
+ test("the other managers keep their own build approval instead of npm's flag", () => {
+ expect(updateCommand("pnpm", "latest", "2.7.24").args).toEqual([
+ "add", "-g", "--allow-build=bun", "@bitkyc08/opencodex@2.7.24",
+ ]);
+ expect(updateCommand("bun", "latest", "2.7.24").args).toEqual([
+ "add", "-g", "@bitkyc08/opencodex@2.7.24",
+ ]);
+ });
+
+ test("the Node launcher's own npm reinstall carries the same approval", () => {
+ // The launcher is plain Node and runs before this module loads, so it repeats the argv.
+ // Source-pinned because loading it executes the CLI.
+ const launcher = readFileSync(repoPath("bin", "ocx.mjs"), "utf8");
+ expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]');
+ // The manual-update failure hint and the fatal runtime message name the same command.
+ expect(launcher).toContain('"npm install -g --allow-scripts=bun @bitkyc08/opencodex"');
+ expect(launcher).toContain('["install", "-g", "--allow-scripts=bun", `${PKG}@${latest || tag}`]');
+ });
+});