From f190a378e9cd004bc4ca82178b815d1b1a7f44df Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:29:16 +0200 Subject: [PATCH 1/3] fix(update): respect mise-owned installations --- bin/ocx.mjs | 18 +- .../docs/fr/reference/cli/lifecycle.md | 2 + .../docs/ja/reference/cli/lifecycle.md | 2 + .../docs/ko/reference/cli/lifecycle.md | 2 + .../content/docs/reference/cli/lifecycle.md | 2 + .../docs/ru/reference/cli/lifecycle.md | 2 + .../docs/tr/reference/cli/lifecycle.md | 2 + .../docs/zh-cn/reference/cli/lifecycle.md | 2 + .../docs/zh-tw/reference/cli/lifecycle.md | 2 + gui/src/components/sidebar-github-row.tsx | 1 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/dashboard-dialogs.tsx | 4 +- gui/src/pages/dashboard-shared.ts | 4 +- scripts/test-layout/layout.json | 1 + src/lib/package-tree-integrity.ts | 2 +- src/update/badge.ts | 4 +- src/update/check-types.ts | 9 + src/update/index.ts | 45 ++- src/update/install-detection.d.mts | 30 +- src/update/install-detection.mjs | 147 ++++++++- src/update/job.ts | 25 +- src/update/notify.ts | 3 +- structure/runtime.md | 2 + .../package-tree-integrity.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/update/update-mise.test.ts | 300 ++++++++++++++++++ 35 files changed, 606 insertions(+), 28 deletions(-) create mode 100644 src/update/check-types.ts create mode 100644 tests/update/update-mise.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index fef7686638c..f25ed4b5638 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; -import { detectInstallFromPath } from "../src/update/install-detection.mjs"; +import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; import { pnpmOwnerInvocation, resolvePnpmGlobalOwner, @@ -70,7 +70,8 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); -const installMethod = detectInstallFromPath(here, { exists: existsSync }); +const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync }); +const installMethod = installOwnership.installer; const cliPath = join(here, "..", "src", "cli", "index.ts"); const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; @@ -916,6 +917,19 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( + `opencodex: this installation is externally managed by mise; update it with: mise upgrade ${installOwnership.owner.tool}`, + ); + } else { + console.error( + "opencodex: this installation appears to be managed by mise, but its ownership metadata is unreadable or inconsistent; repair the mise installation metadata before updating.", + ); + } + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { if (installMethod === "npm") runNpmSelfUpdate(); if (installMethod === "pnpm") runPnpmSelfUpdate(); diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f59ebe9916b..99ac08205c3 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -317,6 +317,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ ### `ocx update [--tag latest|preview]` +Lorsque OpenCodex est installé avec mise, cette commande échoue avant d'arrêter le proxy ou de modifier les fichiers du paquet et affiche `mise upgrade ` avec l'alias mise local vérifié. La vérification des mises à jour reste disponible et signale une gestion externe. Des métadonnées de propriété mise illisibles ou incohérentes bloquent aussi toute modification sans deviner le nom de l'outil, et `--tag preview` ne change jamais la sélection configurée dans mise. + Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. Avant tout arrêt, les installations npm effectuent sous Unix un contrôle borné de la propriété et de l’accès au cache. Les liens symboliques imbriqués sont examinés avec `lstat`, sans être suivis ; Windows ignore explicitement ce contrôle propre à Unix. En cas d’échec, l’opération s’interrompt tandis que l’icône et le proxy fonctionnent encore. Le proxy actif est ensuite arrêté avant le remplacement des fichiers. Un service installé est reconstruit et redémarré automatiquement ; pour une installation au premier plan, la commande indique `ocx start` comme étape suivante. Avant leur conservation, les enregistrements de mise à jour du tableau de bord masquent les chemins de profil et de cache ainsi que les valeurs UID/GID. diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 877954e9755..c822da6d7b8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -286,6 +286,8 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx update [--tag latest|preview]` +OpenCodex が mise 経由でインストールされている場合、このコマンドはプロキシの停止やパッケージファイルの変更前に失敗終了し、検証済みのローカル mise エイリアスを使った `mise upgrade ` を表示します。更新確認は引き続き利用でき、外部管理として報告されます。mise の所有権メタデータを読み取れない場合や整合しない場合もツール名を推測せずに変更を拒否し、`--tag preview` は mise の設定済み選択を変更しません。 + npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 ```bash diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 97048c3768e..e17cdfa03f3 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -406,6 +406,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ### `ocx update [--tag latest|preview]` +OpenCodex가 mise를 통해 설치된 경우 이 명령은 프록시를 중지하거나 패키지 파일을 변경하기 전에 실패하며 검증된 로컬 mise 별칭을 사용한 `mise upgrade `을 표시합니다. 업데이트 확인은 계속 사용할 수 있고 외부 관리 설치로 보고합니다. mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않아도 도구 이름을 추측하지 않고 변경을 거부하며, `--tag preview`는 mise에 구성된 선택을 변경하지 않습니다. + npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3473d3b19cc..66e30b0191b 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -700,6 +700,8 @@ package registry or install an update. ### `ocx update [--tag latest|preview]` +When OpenCodex is installed through mise, this command exits unsuccessfully before stopping the proxy or changing package files and shows `mise upgrade `, using the verified local mise alias. Update checks remain available and report the installation as externally managed. An unreadable or inconsistent mise ownership record fails closed without guessing a tool name, and `--tag preview` never changes mise's configured selection. + Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 6719e9cf2cb..c446c0d20ba 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -411,6 +411,8 @@ one-click управление прокси. `start` и `stop` управляю ### `ocx update [--tag latest|preview]` +Если OpenCodex установлен через mise, команда завершается с ошибкой до остановки прокси или изменения файлов пакета и показывает `mise upgrade ` с проверенным локальным псевдонимом mise. Проверка обновлений остаётся доступной и сообщает о внешнем управлении. Нечитаемые или противоречивые метаданные владельца mise также запрещают изменения без угадывания имени инструмента, а `--tag preview` никогда не меняет выбранную в mise версию. + Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index f1c30392609..b7afadb21be 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -486,6 +486,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yö ### `ocx update [--tag latest|preview]` +OpenCodex mise üzerinden kurulduğunda bu komut proxy'yi durdurmadan veya paket dosyalarını değiştirmeden önce başarısız olur ve doğrulanmış yerel mise diğer adını kullanarak `mise upgrade ` komutunu gösterir. Güncelleme denetimi kullanılabilir kalır ve kurulumun harici olarak yönetildiğini bildirir. Okunamayan veya tutarsız mise sahiplik meta verileri de araç adını tahmin etmeden değişikliği reddeder; `--tag preview` mise içinde yapılandırılmış seçimi değiştirmez. + opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` kullanır; önizleme kurulumları `--tag latest|preview` iletmediğiniz sürece `@preview` üzerinde kalır. Bir kaynak kod kopyasını algılar ve bunun yerine `git diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index b1fdef059a3..a2061e36693 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -273,6 +273,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +当 OpenCodex 由 mise 安装时,此命令会在停止代理或修改软件包文件之前以失败状态退出,并使用经过验证的本地 mise 别名显示 `mise upgrade `。更新检查仍然可用,并会报告该安装由外部管理。无法读取或不一致的 mise 所有权元数据也会阻止修改,且不会猜测工具名称;`--tag preview` 绝不会更改 mise 中配置的选择。 + 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 ```bash diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 4317ef37b30..957a9f0ec40 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -258,6 +258,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +當 OpenCodex 由 mise 安裝時,此命令會在停止代理或修改套件檔案之前以失敗狀態結束,並使用經過驗證的本機 mise 別名顯示 `mise upgrade `。更新檢查仍可使用,並會回報該安裝由外部管理。無法讀取或不一致的 mise 擁有權中繼資料也會阻止修改,且不會猜測工具名稱;`--tag preview` 絕不會變更 mise 中設定的選擇。 + 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 `git pull && bun install`,且若你已是該 tag 的最新版本則為 no-op。執行中的代理會在檔案被替換前停止;已安裝的服務會自動重建並啟動,而前景安裝會印出 `ocx start` 作為下一步。 diff --git a/gui/src/components/sidebar-github-row.tsx b/gui/src/components/sidebar-github-row.tsx index 350cf0cb43d..e6e02c239d8 100644 --- a/gui/src/components/sidebar-github-row.tsx +++ b/gui/src/components/sidebar-github-row.tsx @@ -28,6 +28,7 @@ interface StarStatus { interface UpdateBadge { updateAvailable?: boolean; latestVersion?: string | null; + installer?: "bun" | "mise" | "npm" | "pnpm" | "source"; /** True when no cached registry answer exists, so "no update" is unproven. */ unknown?: boolean; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b28662b265a..1c285d1e4a3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -435,6 +435,8 @@ export const de: Record = { "dash.updateReason.source_checkout": "Quellcode-Checkout", "dash.updateReason.latest_unavailable": "npm-Registry nicht erreichbar", "dash.updateReason.already_latest": "bereits auf dem neuesten Stand", + "dash.updateReason.externally_managed": "extern von mise verwaltet; führe den angezeigten Befehl aus", + "dash.updateReason.external_ownership_invalid": "mise-Eigentümerdaten sind nicht lesbar oder widersprüchlich", "dash.updateReason.unknown": "Update nicht verfügbar", "dash.updateRestart": "Nach Update neu starten", "dash.updateRestartHint": "Empfohlen. Die aktuelle GUI läuft weiter mit altem Code, bis der Proxy neu startet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f3f03e21c79..f58c65d1e8d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -456,6 +456,8 @@ export const en = { "dash.updateReason.source_checkout": "source checkout", "dash.updateReason.latest_unavailable": "npm registry unreachable", "dash.updateReason.already_latest": "already on latest", + "dash.updateReason.externally_managed": "managed externally by mise; run the shown command", + "dash.updateReason.external_ownership_invalid": "mise ownership metadata is unreadable or inconsistent", "dash.updateReason.unknown": "update unavailable", "dash.updateRestart": "Restart after update", "dash.updateRestartHint": "Recommended. The current GUI keeps running the old code until the proxy restarts.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 34525f7e4a1..78d7a401613 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -446,6 +446,8 @@ export const fr: Record = { "dash.updateReason.source_checkout": "extraction du code source", "dash.updateReason.latest_unavailable": "registre npm inaccessible", "dash.updateReason.already_latest": "dernière version déjà installée", + "dash.updateReason.externally_managed": "géré par mise ; exécutez la commande affichée", + "dash.updateReason.external_ownership_invalid": "les métadonnées de propriété mise sont illisibles ou incohérentes", "dash.updateReason.unknown": "mise à jour indisponible", "dash.updateRestart": "Redémarrer après la mise à jour", "dash.updateRestartHint": "Recommandé. L’interface graphique actuelle continue d’exécuter l’ancien code jusqu’au redémarrage du proxy.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dcf3ce28623..a77ea58c987 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -444,6 +444,8 @@ export const ja: Record = { "dash.updateReason.source_checkout": "ソースチェックアウト", "dash.updateReason.latest_unavailable": "npm レジストリに到達できません", "dash.updateReason.already_latest": "最新です", + "dash.updateReason.externally_managed": "mise によって外部管理されています。表示されたコマンドを実行してください", + "dash.updateReason.external_ownership_invalid": "mise の所有権メタデータを読み取れないか、整合していません", "dash.updateReason.unknown": "更新は利用できません", "dash.updateRestart": "更新後に再起動", "dash.updateRestartHint": "推奨。プロキシが再起動されるまで現在の GUI は古いコードを実行し続けます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68c11214149..2b86aa83ca5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -442,6 +442,8 @@ export const ko: Record = { "dash.updateReason.source_checkout": "소스 체크아웃", "dash.updateReason.latest_unavailable": "npm 레지스트리에 연결할 수 없음", "dash.updateReason.already_latest": "이미 최신 버전", + "dash.updateReason.externally_managed": "mise에서 외부 관리 중입니다. 표시된 명령을 실행하세요", + "dash.updateReason.external_ownership_invalid": "mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않습니다", "dash.updateReason.unknown": "업데이트 불가", "dash.updateRestart": "업데이트 후 재시작", "dash.updateRestartHint": "권장. 프록시를 재시작하기 전까지 현재 GUI는 이전 코드로 계속 실행됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 966c173fe0a..567a5910d18 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -444,6 +444,8 @@ export const ru: Record = { "dash.updateReason.source_checkout": "установка из исходного кода", "dash.updateReason.latest_unavailable": "реестр npm недоступен", "dash.updateReason.already_latest": "уже установлена последняя версия", + "dash.updateReason.externally_managed": "управляется mise; выполните показанную команду", + "dash.updateReason.external_ownership_invalid": "метаданные владельца mise недоступны или противоречивы", "dash.updateReason.unknown": "обновление недоступно", "dash.updateRestart": "Перезапустить после обновления", "dash.updateRestartHint": "Рекомендуется. Текущий GUI продолжает работать на старом коде, пока прокси не перезапустится.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4f1053581f2..58821ea0c91 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -448,6 +448,8 @@ export const tr: Record = { "dash.updateReason.source_checkout": "kaynak kod kopyası", "dash.updateReason.latest_unavailable": "npm sunucusuna ulaşılamıyor", "dash.updateReason.already_latest": "zaten en son sürümde", + "dash.updateReason.externally_managed": "mise tarafından harici olarak yönetiliyor; gösterilen komutu çalıştırın", + "dash.updateReason.external_ownership_invalid": "mise sahiplik meta verileri okunamıyor veya tutarsız", "dash.updateReason.unknown": "güncelleme kullanılamıyor", "dash.updateRestart": "Güncellemeden sonra yeniden başlat", "dash.updateRestartHint": "Önerilir. Proxy yeniden başlayana kadar mevcut GUI eski kodu çalıştırmaya devam eder.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8cb83da7079..89824e4e560 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -440,6 +440,8 @@ export const vi: Record = { "dash.updateReason.source_checkout": "checkout mã nguồn", "dash.updateReason.latest_unavailable": "không thể kết nối với registry npm", "dash.updateReason.already_latest": "đã ở phiên bản mới nhất", + "dash.updateReason.externally_managed": "được mise quản lý bên ngoài; hãy chạy lệnh được hiển thị", + "dash.updateReason.external_ownership_invalid": "siêu dữ liệu quyền sở hữu của mise không đọc được hoặc không nhất quán", "dash.updateReason.unknown": "cập nhật không khả dụng", "dash.updateRestart": "Khởi động lại sau khi cập nhật", "dash.updateRestartHint": "Khuyên dùng. GUI hiện tại vẫn tiếp tục chạy mã nguồn cũ cho đến khi proxy khởi động lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c3482290e58..47198dfa9ae 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -334,6 +334,8 @@ export const zhTW: Record = { "dash.updateReason.source_checkout": "原始碼檢出", "dash.updateReason.latest_unavailable": "無法連線 npm 登入檔", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;請執行顯示的命令", + "dash.updateReason.external_ownership_invalid": "mise 擁有權中繼資料無法讀取或不一致", "dash.updateReason.unknown": "無法更新", "dash.updateRestart": "更新後重新啟動", "dash.updateRestartHint": "推薦開啟。代理重新啟動前,當前 GUI 仍執行舊程式碼。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7655f8b09e0..a2ae213d3ac 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -439,6 +439,8 @@ export const zh: Record = { "dash.updateReason.source_checkout": "源码检出", "dash.updateReason.latest_unavailable": "无法连接 npm 注册表", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;请运行显示的命令", + "dash.updateReason.external_ownership_invalid": "mise 所有权元数据无法读取或不一致", "dash.updateReason.unknown": "无法更新", "dash.updateRestart": "更新后重启", "dash.updateRestartHint": "推荐开启。代理重启前,当前 GUI 仍运行旧代码。", diff --git a/gui/src/pages/dashboard-dialogs.tsx b/gui/src/pages/dashboard-dialogs.tsx index 4b96651d0fa..f0f10050680 100644 --- a/gui/src/pages/dashboard-dialogs.tsx +++ b/gui/src/pages/dashboard-dialogs.tsx @@ -72,7 +72,9 @@ export function DashboardDialogs(d: Dash) { {updateCheck.updateAvailable ? t("dash.updateAvailable") : t("dash.updateCurrent")} -
{t("dash.updateCommand")} {updateCheck.command}
+ {updateCheck.command && ( +
{t("dash.updateCommand")} {updateCheck.command}
+ )} {updateCheck.reason === "source_checkout" && (
{t("dash.updateSource")}
)} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index ae24f861a2e..d96ec065cdd 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -125,7 +125,7 @@ export interface SidecarPatch { export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; -export type Installer = "npm" | "bun" | "source"; +export type Installer = "bun" | "mise" | "npm" | "pnpm" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; export interface SyncResult { ok: boolean; @@ -189,6 +189,8 @@ export function updateReasonLabel(reason: string | undefined, t: (key: TKey) => case "source_checkout": return t("dash.updateReason.source_checkout"); case "latest_unavailable": return t("dash.updateReason.latest_unavailable"); case "already_latest": return t("dash.updateReason.already_latest"); + case "externally_managed": return t("dash.updateReason.externally_managed"); + case "external_ownership_invalid": return t("dash.updateReason.external_ownership_invalid"); default: return t("dash.updateReason.unknown"); } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0cbb0fc7b38..b1505a12b79 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1491,6 +1491,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 30372de183e..fed6913f4de 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -17,7 +17,7 @@ export interface PackageTreeIntegrityGuard { } type ObservePackageTree = () => PackageTreeObservation | null; -type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source"; +type PackageTreeRuntimeInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; const packageManifestUrl = new URL("../../package.json", import.meta.url); diff --git a/src/update/badge.ts b/src/update/badge.ts index 0c373c1b715..fabfe746141 100644 --- a/src/update/badge.ts +++ b/src/update/badge.ts @@ -22,6 +22,7 @@ export interface UpdateBadge { currentVersion: string; latestVersion: string | null; channel: Channel; + installer: ReturnType; /** False for source checkouts, where the GUI cannot offer a one-click update. */ canUpdate: boolean; /** True when no cached registry answer exists yet, so "no update" is unproven. */ @@ -53,7 +54,8 @@ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadg currentVersion: current, latestVersion: null, channel, - canUpdate: installer !== "source", + installer, + canUpdate: installer !== "source" && installer !== "mise", unknown: true, }; // A source checkout has nothing to compare against, so "unknown" is not useful there. diff --git a/src/update/check-types.ts b/src/update/check-types.ts new file mode 100644 index 00000000000..bbf5ebdbd28 --- /dev/null +++ b/src/update/check-types.ts @@ -0,0 +1,9 @@ +import type { Channel, Installer, InstallOwnership } from "./index"; + +export interface UpdateCheckDeps { + currentVersion: () => string; + detectInstall: () => Installer; + detectInstallOwnership?: () => InstallOwnership; + latestVersion: (tag: Channel) => string | null; + miseUpdateCommand?: (ownership: InstallOwnership) => string | null; +} diff --git a/src/update/index.ts b/src/update/index.ts index a8af29661dc..b4f36af829d 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -14,7 +14,15 @@ import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; -import { detectInstallFromPath } from "./install-detection.mjs"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "./install-detection.mjs"; +import type { + DetectedInstall, + InstallOwnership, + MiseInstallOwner, +} from "./install-detection.d.mts"; import { pnpmOwnerInvocation, readPnpmGlobalPackage, @@ -49,14 +57,28 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean { export const PKG = "@bitkyc08/opencodex"; const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update -export type Installer = "bun" | "npm" | "pnpm" | "source"; +export type Installer = DetectedInstall; export type Channel = "latest" | "preview"; +export type { InstallOwnership, MiseInstallOwner }; /** Infer how opencodex is installed from the running module's path. */ export function detectInstall(): Installer { return detectInstallFromPath(HERE, { exists: existsSync }); } +/** Resolve installer ownership and verified mise update guidance for this package. */ +export function detectInstallOwnership(): InstallOwnership { + return detectInstallOwnershipFromPath(HERE, { exists: existsSync }); +} + +export function miseUpdateCommand( + ownership: InstallOwnership = detectInstallOwnership(), +): string | null { + return ownership.installer === "mise" && ownership.owner + ? `mise upgrade ${ownership.owner.tool}` + : null; +} + function packageRoot(): string { return resolve(HERE, "..", ".."); } @@ -267,6 +289,9 @@ export function latestVersion( /** The global-install command opencodex would run to update on this channel. */ export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } { + if (installer === "mise") { + throw new Error("mise-owned installations must be upgraded through mise"); + } // Immutable target: when the registry resolved a concrete version, install exactly // that version — the dist-tag can move between resolution and install (TOCTOU). const target = resolvedVersion || tag; @@ -359,11 +384,25 @@ async function resolvedRuntimeOwnership(): Promise * Bun binary. */ export async function runUpdate(): Promise { - const installer = detectInstall(); + const ownership = detectInstallOwnership(); + const installer = ownership.installer; const current = currentVersion(); const tag = updateTag(current); console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`); + if (installer === "mise") { + const command = miseUpdateCommand(ownership); + if (command) { + console.error(`OpenCodex is externally managed by mise. Update it with: ${command}`); + } else { + console.error( + "OpenCodex appears to be managed by mise, but its ownership metadata is unreadable or inconsistent. Repair the mise installation metadata before updating.", + ); + } + process.exitCode = 1; + return; + } + if (installer === "source") { console.log("Running from a source checkout — update with: git pull && bun install"); return; diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts index 88f68ba7318..0f8490d2c62 100644 --- a/src/update/install-detection.d.mts +++ b/src/update/install-detection.d.mts @@ -1,6 +1,32 @@ -export type DetectedInstall = "bun" | "npm" | "pnpm" | "source"; +export type DetectedInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; + +export interface MiseInstallOwner { + tool: string; + backend: string; + installPath: string; + toolRoot: string; +} + +export type InstallOwnership = + | { installer: Exclude } + | { + installer: "mise"; + owner: MiseInstallOwner | null; + error?: "metadata_unreadable" | "metadata_inconsistent"; + }; + +export interface InstallDetectionDeps { + exists?: (path: string) => boolean; + readFile?: (path: string) => string; + realpath?: (path: string) => string; +} export declare function detectInstallFromPath( packagePath: string, - deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string }, + deps?: InstallDetectionDeps, ): DetectedInstall; + +export declare function detectInstallOwnershipFromPath( + packagePath: string, + deps?: InstallDetectionDeps, +): InstallOwnership; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index e21064c9b54..97115ecf0ec 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -1,4 +1,25 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; + +const OPENCODEX_MISE_BACKEND = "npm:@bitkyc08/opencodex"; + +/** + * @typedef {{ + * tool: string; + * backend: string; + * installPath: string; + * toolRoot: string; + * }} MiseInstallOwner + */ + +/** + * @typedef {{ + * installer: "bun" | "npm" | "pnpm" | "source"; + * } | { + * installer: "mise"; + * owner: MiseInstallOwner | null; + * error?: "metadata_unreadable" | "metadata_inconsistent"; + * }} InstallOwnership + */ /** * Infer the package manager from the path of the running package. @@ -14,7 +35,28 @@ import { realpathSync } from "node:fs"; * virtual store. */ export function detectInstallFromPath(packagePath, deps = {}) { - const exists = deps.exists; + return detectInstallOwnershipFromPath(packagePath, deps).installer; +} + +/** + * Infer the outer owner of the running package. + * + * mise's npm backend deliberately contains an ordinary npm/aube installation, so + * package-manager layout alone reports npm. The adjacent backend record is the + * stronger ownership signal: it identifies the mise alias and canonical backend, + * while containment proves that the running package belongs to that installation. + * + * @param {string} packagePath + * @param {{ + * exists?: (path: string) => boolean; + * readFile?: (path: string) => string; + * realpath?: (path: string) => string; + * }} deps + * @returns {InstallOwnership} + */ +export function detectInstallOwnershipFromPath(packagePath, deps = {}) { + const exists = deps.exists ?? existsSync; + const readFile = deps.readFile ?? (path => readFileSync(path, "utf8")); const candidates = [String(packagePath)]; try { const resolved = (deps.realpath ?? realpathSync)(String(packagePath)); @@ -24,13 +66,106 @@ export function detectInstallFromPath(packagePath, deps = {}) { // realpath. The lexical path still carries the evidence when it is available. } - let sawNodeModules = false; + let detectedManager = "source"; + /** @type {MiseInstallOwner[]} */ + const miseOwners = []; + /** @type {"metadata_unreadable" | "metadata_inconsistent" | undefined} */ + let miseError; for (const candidate of candidates) { + const mise = detectMiseOwner(candidate, { exists, readFile }); + if (mise.recognized) { + if (mise.owner) miseOwners.push(mise.owner); + else miseError = mise.error; + } const detected = detectInstallCandidate(candidate, exists); - if (detected === "pnpm" || detected === "bun") return detected; - if (detected === "npm") sawNodeModules = true; + if (detected === "pnpm" || detected === "bun") detectedManager = detected; + else if (detected === "npm" && detectedManager === "source") detectedManager = "npm"; + } + // A broken ownership boundary on either spelling wins over a verified one. Using the + // other candidate could authorize mutation across a lexical/resolved-path mismatch. + if (miseError) return { installer: "mise", owner: null, error: miseError }; + const miseOwner = miseOwners.at(-1); + if (miseOwner) { + const consistent = miseOwners.every(owner => + owner.tool === miseOwner.tool + && owner.backend === miseOwner.backend + && samePath(owner.toolRoot, miseOwner.toolRoot) + ); + return consistent + ? { installer: "mise", owner: miseOwner } + : { installer: "mise", owner: null, error: "metadata_inconsistent" }; } - return sawNodeModules ? "npm" : "source"; + return { installer: detectedManager }; +} + +function parseBackendMetadata(content) { + const fields = new Map(); + for (const line of String(content).split(/\r?\n/)) { + const match = /^\s*(short|full)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/.exec(line); + if (!match) continue; + if (fields.has(match[1])) return null; + try { + fields.set( + match[1], + match[2].startsWith('"') ? JSON.parse(match[2]) : match[2].slice(1, -1), + ); + } catch { + return null; + } + } + const tool = fields.get("short"); + const backend = fields.get("full"); + return typeof tool === "string" && typeof backend === "string" + ? { tool, backend } + : null; +} + +function detectMiseOwner(packagePath, deps) { + const normalized = String(packagePath).replaceAll("\\", "/").replace(/\/+$/, ""); + const lower = normalized.toLowerCase(); + let marker = -1; + let installPath; + let toolRoot; + let metadataPath; + while ((marker = lower.indexOf("/node_modules/", marker + 1)) >= 1) { + installPath = normalized.slice(0, marker); + const slash = installPath.lastIndexOf("/"); + if (slash < 1) continue; + toolRoot = installPath.slice(0, slash); + metadataPath = `${toolRoot}/.mise.backend.toml`; + if (deps.exists(metadataPath)) break; + metadataPath = undefined; + } + if (!metadataPath || !installPath || !toolRoot) return { recognized: false }; + + let metadata; + try { + metadata = parseBackendMetadata(deps.readFile(metadataPath)); + } catch { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } + const toolDir = toolRoot.slice(toolRoot.lastIndexOf("/") + 1); + if ( + !metadata + || metadata.backend !== OPENCODEX_MISE_BACKEND + || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(metadata.tool) + || !samePath(metadata.tool, toolDir, /^[A-Za-z]:\//.test(normalized)) + ) { + return { recognized: true, owner: null, error: "metadata_inconsistent" }; + } + return { + recognized: true, + owner: { + tool: metadata.tool, + backend: metadata.backend, + installPath, + toolRoot, + }, + }; +} + +function samePath(left, right, windows = /^[A-Za-z]:\//.test(left) && /^[A-Za-z]:\//.test(right)) { + return windows ? left.toLowerCase() === right.toLowerCase() : left === right; } function detectInstallCandidate(packagePath, exists) { diff --git a/src/update/job.ts b/src/update/job.ts index dd8e47748e5..a216f48a1cf 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -35,12 +35,16 @@ import { currentVersion, defaultUpdateTag, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, updateCommand, updateCommandStr, resolveCurrentPnpmGlobalOwner, resolvePnpmActiveLauncher, } from "./index"; +import type { UpdateCheckDeps } from "./check-types"; +export type { UpdateCheckDeps } from "./check-types"; import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; @@ -107,12 +111,6 @@ export class UpdateJobError extends Error { } } -export interface UpdateCheckDeps { - currentVersion: () => string; - detectInstall: () => Installer; - latestVersion: (tag: Channel) => string | null; -} - interface UpdateWorkerProcess { pid?: number; unref(): void; @@ -129,7 +127,9 @@ export interface StartUpdateJobDeps { const defaultCheckDeps: UpdateCheckDeps = { currentVersion, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, }; function nodeBin(): string { @@ -495,16 +495,23 @@ export function checkForUpdate( deps: UpdateCheckDeps = defaultCheckDeps, ): UpdateCheckResult { const current = deps.currentVersion(); - const installer = deps.detectInstall(); + const ownership = deps.detectInstallOwnership?.(); + const installer = ownership?.installer ?? deps.detectInstall(); const channel = requestedChannel ?? normalizeUpdateChannel(null, current); const latest = installer === "source" ? null : deps.latestVersion(channel); const updateAvailable = !!latest && isNewer(latest, current, channel); let reason: string | undefined; - let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display; + let command = installer === "source" + ? manualSourceCommand() + : installer === "mise" + ? (ownership && deps.miseUpdateCommand?.(ownership)) ?? "" + : updateExecutionCommand(installer, channel).display; if (installer === "source") { reason = "source_checkout"; command = manualSourceCommand(); + } else if (installer === "mise") { + reason = command ? "externally_managed" : "external_ownership_invalid"; } else if (!latest) { reason = "latest_unavailable"; } else if (!updateAvailable) { @@ -517,7 +524,7 @@ export function checkForUpdate( channel, installer, updateAvailable, - canUpdate: installer !== "source" && updateAvailable, + canUpdate: installer !== "source" && installer !== "mise" && updateAvailable, command, releaseNotesUrl: RELEASE_NOTES_URL, ...(reason ? { reason } : {}), diff --git a/src/update/notify.ts b/src/update/notify.ts index 5764af3992c..9331440aa4d 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -139,7 +139,8 @@ export function interactiveGuardOk(): boolean { * the one-time star prompt has already run (first-run yield, O1). */ export function shouldConsider(): { channel: Channel; current: string } | null { - if (detectInstall() === "source") return null; + const installer = detectInstall(); + if (installer === "source" || installer === "mise") return null; const current = currentVersion(); if (current === "?" || isSourceBuildVersion(current)) return null; if (!interactiveGuardOk()) return null; diff --git a/structure/runtime.md b/structure/runtime.md index 384b6b394e6..bd0f23e094c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -114,6 +114,8 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour ## Entrypoints +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. Unreadable or contradictory ownership metadata also refuses mutation without inventing a tool name. The package-tree integrity guard remains active for mise packages. + | Path | Responsibility | | --- | --- | | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index 13da9e02e7c..5d91ca2530c 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -73,7 +73,7 @@ describe("package tree integrity", () => { expect(installedGuard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); }); - test.each(["npm", "bun"] as const)("%s installs still refuse a replaced package tree", installer => { + test.each(["npm", "bun", "mise"] as const)("%s installs still refuse a replaced package tree", installer => { let observation: PackageTreeObservation = { device: 1n, inode: 10n, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c69955e4208..ef28645d10b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1320,6 +1320,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts new file mode 100644 index 00000000000..e9c8d5da985 --- /dev/null +++ b/tests/update/update-mise.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from "bun:test"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "../../src/update/install-detection.mjs"; +import { checkForUpdate, startUpdateJob, UpdateJobError } from "../../src/update/job"; +import { readUpdateBadge } from "../../src/update/badge"; +import type { InstallOwnership } from "../../src/update/index"; + +const BACKEND = 'short = "ocx-local"\nfull = "npm:@bitkyc08/opencodex"\nexplicit_backend = false\n'; + +function misePackage(root: string, version = "2.59.0"): string { + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const packagePath = join( + toolRoot, + version, + "node_modules", + ".mise", + "@bitkyc08+opencodex@2.59.0", + "node_modules", + "@bitkyc08", + "opencodex", + "bin", + ); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(toolRoot, ".mise.backend.toml"), BACKEND); + return packagePath; +} + +describe("mise installation ownership", () => { + test("recognises a custom data directory, local alias, and nested aube package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-owner-")); + try { + const packagePath = misePackage(root); + expect(detectInstallOwnershipFromPath(packagePath)).toEqual({ + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: join(root, "custom mise data", "installs", "ocx-local", "2.59.0"), + toolRoot: join(root, "custom mise data", "installs", "ocx-local"), + }, + }); + expect(detectInstallFromPath(packagePath)).toBe("mise"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the resolved exact version behind a floating link", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-link-")); + try { + const exact = misePackage(root); + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const latest = join(toolRoot, "latest"); + if (process.platform === "win32") { + symlinkSync(join(toolRoot, "2.59.0"), latest, "junction"); + } else { + symlinkSync("2.59.0", latest, "dir"); + } + const floating = join(toolRoot, "latest", exact.slice(join(toolRoot, "2.59.0").length + 1)); + expect(detectInstallOwnershipFromPath(floating)).toMatchObject({ + installer: "mise", + owner: { tool: "ocx-local", installPath: join(toolRoot, "2.59.0") }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not infer mise ownership from a .mise path or mise on PATH", () => { + const path = "/tmp/.mise/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + realpath: value => value, + })).toEqual({ installer: "npm" }); + }); + + test("finds the install boundary when the custom data directory contains node_modules", () => { + const path = "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/node_modules/mise-data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { installPath: "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0" }, + }); + }); + + test("fails closed when adjacent ownership metadata is unreadable or contradictory", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + readFile: () => { throw new Error("denied"); }, + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + readFile: () => 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("fails closed when lexical and resolved ownership evidence disagree", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("keeps a broken ownership boundary authoritative when the other path verifies", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + readFile: value => { + if (value.startsWith("/other/")) throw new Error("denied"); + return BACKEND; + }, + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); + + test("handles Windows spelling without treating path case as an ownership mismatch", () => { + const lexical = "C:\\Data Root\\mise\\installs\\OpenCodex\\2.59.0\\node_modules\\@bitkyc08\\opencodex\\bin"; + const resolved = "C:/Data Root/mise/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = new Set([ + "C:/Data Root/mise/installs/OpenCodex/.mise.backend.toml", + "C:/Data Root/mise/installs/opencodex/.mise.backend.toml", + ]); + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => metadata.has(value), + readFile: () => 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toMatchObject({ installer: "mise", owner: { tool: "opencodex" } }); + }); +}); + +describe("mise update refusal", () => { + const ownership: InstallOwnership = { + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: "/data/installs/ocx-local/2.59.0", + toolRoot: "/data/installs/ocx-local", + }, + }; + + test("read-only checks succeed with actionable external-management guidance", () => { + const result = checkForUpdate("preview", { + currentVersion: () => "2.59.0", + detectInstall: () => "npm", + detectInstallOwnership: () => ownership, + latestVersion: () => "2.60.0-preview.1", + miseUpdateCommand: value => value.installer === "mise" && value.owner + ? `mise upgrade ${value.owner.tool}` + : null, + }); + + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + channel: "preview", + }); + }); + + test("invalid metadata never invents a tool name", () => { + const result = checkForUpdate("latest", { + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + detectInstallOwnership: () => ({ + installer: "mise", + owner: null, + error: "metadata_inconsistent", + }), + latestVersion: () => "2.60.0", + miseUpdateCommand: () => null, + }); + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "external_ownership_invalid", + command: "", + }); + }); + + test("the sidebar badge can report availability without offering mutation", () => { + const badge = readUpdateBadge({ + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + readCache: () => ({ + latest_version: "2.60.0", + last_checked_at: new Date().toISOString(), + tag: "latest", + }), + }); + expect(badge.updateAvailable).toBe(true); + expect(badge.canUpdate).toBe(false); + expect(badge.installer).toBe("mise"); + }); + + test("dashboard update requests are rejected before a worker is created", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-job-")); + const previousHome = process.env.OPENCODEX_HOME; + let spawned = false; + process.env.OPENCODEX_HOME = root; + try { + let thrown: unknown; + try { + startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.59.0", + latestVersion: "2.60.0", + channel: "latest", + installer: "mise", + updateAvailable: true, + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { + spawned = true; + throw new Error("must not spawn"); + }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(UpdateJobError); + expect(thrown).toMatchObject({ code: "externally_managed", status: 409 }); + expect(spawned).toBe(false); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(root, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")("the published Node launcher refuses before npm or Bun update handling", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-launcher-")); + try { + const toolRoot = join(root, "data root", "installs", "ocx-local"); + const packageParent = join(toolRoot, "2.59.0", "node_modules", "@bitkyc08"); + const packagePath = join(packageParent, "opencodex"); + const fakeBin = join(root, "fake-bin"); + mkdirSync(packageParent, { recursive: true }); + mkdirSync(fakeBin); + symlinkSync(join(import.meta.dir, "..", ".."), packagePath, "dir"); + writeFileSync(join(toolRoot, ".mise.backend.toml"), BACKEND); + const fakeNpm = join(fakeBin, "npm"); + writeFileSync(fakeNpm, "#!/bin/sh\nprintf '%s\\n' 2.59.0\n"); + chmodSync(fakeNpm, 0o755); + + const result = spawnSync( + "node", + ["--preserve-symlinks-main", join(packagePath, "bin", "ocx.mjs"), "update", "--tag", "preview"], + { + encoding: "utf8", + env: { + ...process.env, + OPENCODEX_HOME: join(root, "state"), + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }, + }, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("externally managed by mise"); + expect(result.stderr).toContain("mise upgrade ocx-local"); + expect(result.stderr).not.toContain("tag preview"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From ad8b52d8d27b57bed114938cd00ddaef061bd510 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:03:02 +0200 Subject: [PATCH 2/3] test(update): make mise ownership precedence explicit --- src/update/install-detection.mjs | 5 +++-- structure/runtime.md | 2 +- tests/update/update-mise.test.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index 97115ecf0ec..ca647705bc2 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -81,8 +81,9 @@ export function detectInstallOwnershipFromPath(packagePath, deps = {}) { if (detected === "pnpm" || detected === "bun") detectedManager = detected; else if (detected === "npm" && detectedManager === "source") detectedManager = "npm"; } - // A broken ownership boundary on either spelling wins over a verified one. Using the - // other candidate could authorize mutation across a lexical/resolved-path mismatch. + // Any recognized ownership error on either spelling takes precedence over every verified + // owner. Keeping a command from the other candidate could authorize mutation across a + // lexical/resolved-path mismatch, so fail closed without recovery guidance. if (miseError) return { installer: "mise", owner: null, error: miseError }; const miseOwner = miseOwners.at(-1); if (miseOwner) { diff --git a/structure/runtime.md b/structure/runtime.md index bd0f23e094c..ca5853c6221 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -114,7 +114,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour ## Entrypoints -`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. Unreadable or contradictory ownership metadata also refuses mutation without inventing a tool name. The package-tree integrity guard remains active for mise packages. +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. | Path | Responsibility | | --- | --- | diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts index e9c8d5da985..7298450c6fa 100644 --- a/tests/update/update-mise.test.ts +++ b/tests/update/update-mise.test.ts @@ -141,6 +141,18 @@ describe("mise installation ownership", () => { })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); }); + test("does not let a verified owner override contradictory metadata on the other path", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + test("handles Windows spelling without treating path case as an ownership mismatch", () => { const lexical = "C:\\Data Root\\mise\\installs\\OpenCodex\\2.59.0\\node_modules\\@bitkyc08\\opencodex\\bin"; const resolved = "C:/Data Root/mise/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; From edcb214a14de8b5efdf33390a48fed6e9ce041b7 Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:42:21 +0200 Subject: [PATCH 3/3] fix(update): fail closed on metadata probe errors --- src/update/install-detection.d.mts | 1 + src/update/install-detection.mjs | 43 +++++++++++++++++++++++----- src/update/job.ts | 4 +-- tests/update/update-mise.test.ts | 45 ++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts index 0f8490d2c62..7ede7997c27 100644 --- a/src/update/install-detection.d.mts +++ b/src/update/install-detection.d.mts @@ -17,6 +17,7 @@ export type InstallOwnership = export interface InstallDetectionDeps { exists?: (path: string) => boolean; + probe?: (path: string) => "present" | "absent" | "unreadable"; readFile?: (path: string) => string; realpath?: (path: string) => string; } diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index ca647705bc2..ef6f13c4058 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -1,6 +1,7 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; const OPENCODEX_MISE_BACKEND = "npm:@bitkyc08/opencodex"; +const OPENCODEX_MISE_BACKEND_DIR = "npm-bitkyc08-opencodex"; /** * @typedef {{ @@ -49,6 +50,7 @@ export function detectInstallFromPath(packagePath, deps = {}) { * @param {string} packagePath * @param {{ * exists?: (path: string) => boolean; + * probe?: (path: string) => "present" | "absent" | "unreadable"; * readFile?: (path: string) => string; * realpath?: (path: string) => string; * }} deps @@ -56,6 +58,7 @@ export function detectInstallFromPath(packagePath, deps = {}) { */ export function detectInstallOwnershipFromPath(packagePath, deps = {}) { const exists = deps.exists ?? existsSync; + const probe = deps.probe ?? probeMetadata; const readFile = deps.readFile ?? (path => readFileSync(path, "utf8")); const candidates = [String(packagePath)]; try { @@ -72,7 +75,7 @@ export function detectInstallOwnershipFromPath(packagePath, deps = {}) { /** @type {"metadata_unreadable" | "metadata_inconsistent" | undefined} */ let miseError; for (const candidate of candidates) { - const mise = detectMiseOwner(candidate, { exists, readFile }); + const mise = detectMiseOwner(candidate, { probe, readFile }); if (mise.recognized) { if (mise.owner) miseOwners.push(mise.owner); else miseError = mise.error; @@ -122,7 +125,10 @@ function parseBackendMetadata(content) { } function detectMiseOwner(packagePath, deps) { - const normalized = String(packagePath).replaceAll("\\", "/").replace(/\/+$/, ""); + const windowsPath = /^[A-Za-z]:[\\/]/.test(String(packagePath)) + || String(packagePath).startsWith("\\\\"); + const normalized = (windowsPath ? String(packagePath).replaceAll("\\", "/") : String(packagePath)) + .replace(/\/+$/, ""); const lower = normalized.toLowerCase(); let marker = -1; let installPath; @@ -134,7 +140,11 @@ function detectMiseOwner(packagePath, deps) { if (slash < 1) continue; toolRoot = installPath.slice(0, slash); metadataPath = `${toolRoot}/.mise.backend.toml`; - if (deps.exists(metadataPath)) break; + const metadataState = deps.probe(metadataPath); + if (metadataState === "present") break; + if (metadataState === "unreadable") { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } metadataPath = undefined; } if (!metadataPath || !installPath || !toolRoot) return { recognized: false }; @@ -146,11 +156,15 @@ function detectMiseOwner(packagePath, deps) { return { recognized: true, owner: null, error: "metadata_unreadable" }; } const toolDir = toolRoot.slice(toolRoot.lastIndexOf("/") + 1); + const expectedToolDir = metadata?.tool === OPENCODEX_MISE_BACKEND + ? OPENCODEX_MISE_BACKEND_DIR + : metadata?.tool; if ( !metadata || metadata.backend !== OPENCODEX_MISE_BACKEND - || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(metadata.tool) - || !samePath(metadata.tool, toolDir, /^[A-Za-z]:\//.test(normalized)) + || (metadata.tool !== OPENCODEX_MISE_BACKEND + && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(metadata.tool)) + || !samePath(expectedToolDir, toolDir, windowsPath) ) { return { recognized: true, owner: null, error: "metadata_inconsistent" }; } @@ -165,12 +179,27 @@ function detectMiseOwner(packagePath, deps) { }; } +function probeMetadata(path) { + try { + statSync(path); + return "present"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? error.code + : undefined; + return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable"; + } +} + function samePath(left, right, windows = /^[A-Za-z]:\//.test(left) && /^[A-Za-z]:\//.test(right)) { return windows ? left.toLowerCase() === right.toLowerCase() : left === right; } function detectInstallCandidate(packagePath, exists) { - const normalized = String(packagePath).replaceAll("\\", "/"); + const path = String(packagePath); + const normalized = /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\") + ? path.replaceAll("\\", "/") + : path; const segments = normalized.split("/").filter(Boolean); // Windows paths are case-insensitive. Treating the structural marker this way also // keeps a preserved-symlink path from being downgraded merely because its casing came diff --git a/src/update/job.ts b/src/update/job.ts index a216f48a1cf..a1494bb23cf 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -28,8 +28,7 @@ import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentit import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { - type Channel, - type Installer, + type Channel, type Installer, PKG, checkUpdatePackageIntegrity, currentVersion, @@ -509,7 +508,6 @@ export function checkForUpdate( if (installer === "source") { reason = "source_checkout"; - command = manualSourceCommand(); } else if (installer === "mise") { reason = command ? "externally_managed" : "external_ownership_invalid"; } else if (!latest) { diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts index 7298450c6fa..c598185fa7f 100644 --- a/tests/update/update-mise.test.ts +++ b/tests/update/update-mise.test.ts @@ -19,6 +19,8 @@ import { readUpdateBadge } from "../../src/update/badge"; import type { InstallOwnership } from "../../src/update/index"; const BACKEND = 'short = "ocx-local"\nfull = "npm:@bitkyc08/opencodex"\nexplicit_backend = false\n'; +const metadataProbe = (exists: (path: string) => boolean) => + (path: string): "present" | "absent" => exists(path) ? "present" : "absent"; function misePackage(root: string, version = "2.59.0"): string { const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); @@ -83,6 +85,7 @@ describe("mise installation ownership", () => { const path = "/tmp/.mise/node_modules/@bitkyc08/opencodex/bin"; expect(detectInstallOwnershipFromPath(path, { exists: () => false, + probe: () => "absent", realpath: value => value, })).toEqual({ installer: "npm" }); }); @@ -92,6 +95,7 @@ describe("mise installation ownership", () => { const metadata = "/tmp/node_modules/mise-data/installs/ocx-local/.mise.backend.toml"; expect(detectInstallOwnershipFromPath(path, { exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), readFile: () => BACKEND, realpath: value => value, })).toMatchObject({ @@ -100,17 +104,45 @@ describe("mise installation ownership", () => { }); }); + test("preserves literal backslashes in POSIX install paths", () => { + const path = "/tmp/mise\\state/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/mise\\state/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { toolRoot: "/tmp/mise\\state/installs/ocx-local" }, + }); + }); + + test("accepts mise's full npm identifier and encoded directory name", () => { + const path = "/data/installs/npm-bitkyc08-opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/npm-bitkyc08-opencodex/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => 'short = "npm:@bitkyc08/opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { tool: "npm:@bitkyc08/opencodex" }, + }); + }); + test("fails closed when adjacent ownership metadata is unreadable or contradictory", () => { const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; const metadata = "/data/installs/ocx-local/.mise.backend.toml"; expect(detectInstallOwnershipFromPath(path, { exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), readFile: () => { throw new Error("denied"); }, realpath: value => value, })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); expect(detectInstallOwnershipFromPath(path, { exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), readFile: () => 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', realpath: value => value, })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); @@ -121,6 +153,7 @@ describe("mise installation ownership", () => { const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; expect(detectInstallOwnershipFromPath(lexical, { exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), readFile: value => value.startsWith("/data/") ? BACKEND : 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', @@ -133,6 +166,7 @@ describe("mise installation ownership", () => { const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; expect(detectInstallOwnershipFromPath(lexical, { exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), readFile: value => { if (value.startsWith("/other/")) throw new Error("denied"); return BACKEND; @@ -146,6 +180,7 @@ describe("mise installation ownership", () => { const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; expect(detectInstallOwnershipFromPath(lexical, { exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), readFile: value => value.startsWith("/data/") ? BACKEND : 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', @@ -162,10 +197,20 @@ describe("mise installation ownership", () => { ]); expect(detectInstallOwnershipFromPath(lexical, { exists: value => metadata.has(value), + probe: metadataProbe(value => metadata.has(value)), readFile: () => 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', realpath: () => resolved, })).toMatchObject({ installer: "mise", owner: { tool: "opencodex" } }); }); + + test("fails closed when probing adjacent metadata is unreadable", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: () => "unreadable", + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); }); describe("mise update refusal", () => {