From 7c39870d90a04316392982681a77ce99c1a3dd7c Mon Sep 17 00:00:00 2001 From: Gary Sassano <10464497+garysassano@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:33:44 +0200 Subject: [PATCH] fix(update): restart mise-managed services onto upgraded versions mise installs each version into its own directory and repoints a floating `latest` link, so `mise upgrade` never touches the manifest the package-tree fence watches. A managed service kept serving the old version until someone restarted it, and once mise pruned that version the fence reported the tree unreadable and answered 503 on every /v1 request without ever restarting. The managed Linux service of a verified mise owner now also watches its recorded package launcher (`//node_modules/.bin/ocx`). An unref'd timer re-resolves it without needing inbound requests; once one complete target identity (canonical package root plus manifest identity) holds for the settle interval, the watch enters the same drain-and-restart handler as the fence, without fencing traffic. systemd then relaunches through the launcher onto the new version, which never restarts again. The watch is armed only with OCX_SERVICE_MANAGED=1 on Linux, for a launcher that resolves to the running package at boot. mise shims, other layouts, launchd (pinned package paths) and foreground proxies keep the existing behavior. --- .../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 + scripts/test-layout/layout.json | 2 + src/lib/package-tree-integrity.ts | 28 ++- src/lib/package-tree-retarget.ts | 130 +++++++++++ src/server/index/package-tree-guard.ts | 46 ++-- src/server/index/startup-warnings.ts | 6 + src/update/mise-launcher-target.ts | 100 ++++++++ structure/ops/docs-and-release.md | 16 ++ structure/ops/service-and-sidecars.md | 2 +- .../package-tree-retarget.test.ts | 218 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + .../update-mise-launcher-target.test.ts | 134 +++++++++++ 19 files changed, 680 insertions(+), 20 deletions(-) create mode 100644 src/lib/package-tree-retarget.ts create mode 100644 src/update/mise-launcher-target.ts create mode 100644 tests/ci-workflows/package-tree-retarget.test.ts create mode 100644 tests/update/update-mise-launcher-target.test.ts 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 b58247b64a2..7a1b6cfedbb 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -345,6 +345,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ 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. +Sous Linux, un service en arrière-plan dont le lanceur enregistré est le lanceur de paquet de mise (`/latest/node_modules/.bin/ocx`, et non un shim mise) suit `mise upgrade` tout seul : une dizaine de secondes après la stabilisation de la nouvelle version, il draine les requêtes actives et redémarre sur celle-ci, et il se rétablit de la même façon si mise supprime plus tard la version qu'il exécutait. Sous macOS, pour un service installé via un shim mise et pour un proxy au premier plan, redémarrez-le vous-même après la mise à jour (sous macOS, d'abord `ocx service repair`). + 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 1132c8e380d..b6c5fe4a7db 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -295,6 +295,8 @@ Windows ステータス トレイ アイコンをインストールして制御 OpenCodex が mise 経由でインストールされている場合、このコマンドはプロキシの停止やパッケージファイルの変更前に失敗終了し、検証済みのローカル mise エイリアスを使った `mise upgrade ` を表示します。更新確認は引き続き利用でき、外部管理として報告されます。mise の所有権メタデータを読み取れない場合や整合しない場合もツール名を推測せずに変更を拒否し、`--tag preview` は mise の設定済み選択を変更しません。 +Linux では、記録されたランチャーが mise のパッケージランチャー(mise の shim ではなく `/latest/node_modules/.bin/ocx`)であるバックグラウンドサービスは、`mise upgrade` に自動で追従します。新しいバージョンが安定してから約 10 秒以内に進行中のリクエストをドレインしてそのバージョンで再起動し、実行中のバージョンが後で mise によって削除された場合も同じ方法で復旧します。macOS、mise の shim 経由でインストールしたサービス、フォアグラウンドのプロキシでは、アップグレード後に手動で再起動してください(macOS では先に `ocx service repair`)。 + 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 00459d04060..0c889074403 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -430,6 +430,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 OpenCodex가 mise를 통해 설치된 경우 이 명령은 프록시를 중지하거나 패키지 파일을 변경하기 전에 실패하며 검증된 로컬 mise 별칭을 사용한 `mise upgrade `을 표시합니다. 업데이트 확인은 계속 사용할 수 있고 외부 관리 설치로 보고합니다. mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않아도 도구 이름을 추측하지 않고 변경을 거부하며, `--tag preview`는 mise에 구성된 선택을 변경하지 않습니다. +Linux에서 기록된 런처가 mise 패키지 런처(mise shim이 아닌 `/latest/node_modules/.bin/ocx`)인 백그라운드 서비스는 `mise upgrade`를 스스로 따라갑니다. 새 버전이 안정된 뒤 약 10초 안에 진행 중인 요청을 드레인하고 새 버전으로 재시작하며, 실행 중이던 버전을 mise가 나중에 정리해도 같은 방식으로 복구합니다. macOS, mise shim으로 설치한 서비스, 포그라운드 프록시는 업그레이드 후 직접 재시작하세요(macOS에서는 먼저 `ocx service repair`). + 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 1bb6250d49e..8391cd83cca 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -708,6 +708,8 @@ package registry or install an update. 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. +On Linux, a background service whose recorded launcher is mise's package launcher (`/latest/node_modules/.bin/ocx`, not a mise shim) follows `mise upgrade` by itself: within about ten seconds of the new version settling, it drains active requests and restarts onto it, and it recovers the same way if mise later prunes the version it was running. On macOS, for a service installed through a mise shim, and for a foreground proxy, restart it yourself after upgrading (on macOS, `ocx service repair` first). + 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 27711fd81ef..3b7caf84a68 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -436,6 +436,8 @@ one-click управление прокси. `start` и `stop` управляю Если OpenCodex установлен через mise, команда завершается с ошибкой до остановки прокси или изменения файлов пакета и показывает `mise upgrade ` с проверенным локальным псевдонимом mise. Проверка обновлений остаётся доступной и сообщает о внешнем управлении. Нечитаемые или противоречивые метаданные владельца mise также запрещают изменения без угадывания имени инструмента, а `--tag preview` никогда не меняет выбранную в mise версию. +В Linux фоновая служба, у которой записанный лаунчер — пакетный лаунчер mise (`/latest/node_modules/.bin/ocx`, а не shim mise), сама следует за `mise upgrade`: примерно через десять секунд после того, как новая версия стабилизируется, она завершает активные запросы и перезапускается на ней, и так же восстанавливается, если mise позже удалит версию, на которой она работала. В macOS, для службы, установленной через shim mise, и для прокси на переднем плане перезапустите её сами после обновления (в macOS сначала `ocx service repair`). + Самообновить 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 e40049e5ecf..d2c4a437743 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -510,6 +510,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yö 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. +Linux'ta kayıtlı başlatıcısı mise paket başlatıcısı (mise shim'i değil, `/latest/node_modules/.bin/ocx`) olan bir arka plan hizmeti `mise upgrade` işlemini kendiliğinden izler: yeni sürüm oturduktan yaklaşık on saniye sonra etkin istekleri boşaltır ve yeni sürümle yeniden başlar; mise daha sonra çalıştığı sürümü temizlerse de aynı şekilde toparlanır. macOS'ta, mise shim'i üzerinden kurulan bir hizmette ve ön plandaki bir proxy'de yükseltmeden sonra kendiniz yeniden başlatın (macOS'ta önce `ocx service repair`). + 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 fe59f62903b..c5caee6abb1 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 @@ -282,6 +282,8 @@ ocx codex-shim uninstall 当 OpenCodex 由 mise 安装时,此命令会在停止代理或修改软件包文件之前以失败状态退出,并使用经过验证的本地 mise 别名显示 `mise upgrade `。更新检查仍然可用,并会报告该安装由外部管理。无法读取或不一致的 mise 所有权元数据也会阻止修改,且不会猜测工具名称;`--tag preview` 绝不会更改 mise 中配置的选择。 +在 Linux 上,如果后台服务记录的启动器是 mise 的包启动器(`/latest/node_modules/.bin/ocx`,而不是 mise shim),服务会自动跟随 `mise upgrade`:新版本稳定后约十秒内,它会排空进行中的请求并在新版本上重启;如果 mise 之后清理了它正在运行的版本,也会以同样方式恢复。在 macOS 上、通过 mise shim 安装的服务以及前台代理,请在升级后自行重启(macOS 上先运行 `ocx service repair`)。 + 从 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 6f80d34e094..16899542c02 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 @@ -267,6 +267,8 @@ ocx codex-shim uninstall 當 OpenCodex 由 mise 安裝時,此命令會在停止代理或修改套件檔案之前以失敗狀態結束,並使用經過驗證的本機 mise 別名顯示 `mise upgrade `。更新檢查仍可使用,並會回報該安裝由外部管理。無法讀取或不一致的 mise 擁有權中繼資料也會阻止修改,且不會猜測工具名稱;`--tag preview` 絕不會變更 mise 中設定的選擇。 +在 Linux 上,若背景服務記錄的啟動器是 mise 的套件啟動器(`/latest/node_modules/.bin/ocx`,而非 mise shim),服務會自動跟隨 `mise upgrade`:新版本穩定後約十秒內,它會排空進行中的請求並在新版本上重新啟動;若 mise 之後清除了它正在執行的版本,也會以相同方式復原。在 macOS 上、透過 mise shim 安裝的服務以及前景代理,請在升級後自行重新啟動(macOS 上先執行 `ocx service repair`)。 + 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 `git pull && bun install`,且若你已是該 tag 的最新版本則為 no-op。執行中的代理會在檔案被替換前停止;已安裝的服務會自動重建並啟動,而前景安裝會印出 `ocx start` 作為下一步。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 35a7e5f60ac..603e76215b7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1335,6 +1335,7 @@ "package-tree-fenced-restart.test.ts": "ci-workflows", "package-tree-integrity.test.ts": "ci-workflows", "package-tree-restart-ownership.test.ts": "ci-workflows", + "package-tree-retarget.test.ts": "ci-workflows", "parallel-tool-calls-optin.test.ts": "codex-integration", "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", @@ -1768,6 +1769,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.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/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 5be14e0eb51..2997e63e9ec 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -1,4 +1,6 @@ import { readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { isStandaloneBinary } from "./standalone"; export interface PackageTreeObservation { @@ -63,9 +65,19 @@ const packageManifestUrl = new URL("../../package.json", import.meta.url); const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +/** Directory of the package this process was loaded from, as the module loader spells it. */ +export function runningPackageRoot(): string { + return dirname(fileURLToPath(packageManifestUrl)); +} + function readInstalledManifestVersion(): string | undefined { + return readPackageManifestVersionAt(fileURLToPath(packageManifestUrl)); +} + +/** The `version` of the manifest at `manifestPath`, or undefined when unreadable or malformed. */ +export function readPackageManifestVersionAt(manifestPath: string): string | undefined { try { - const version = (JSON.parse(readFileSync(packageManifestUrl, "utf8")) as { version?: unknown }).version; + const version = (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: unknown }).version; return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version) ? version : undefined; @@ -75,8 +87,13 @@ function readInstalledManifestVersion(): string | undefined { } function observePackageManifest(): PackageTreeObservation | null { + return observePackageManifestAt(fileURLToPath(packageManifestUrl)); +} + +/** Identity of the manifest at `manifestPath`, or null when it cannot be stat'd. */ +export function observePackageManifestAt(manifestPath: string): PackageTreeObservation | null { try { - const stat = statSync(packageManifestUrl, { bigint: true }); + const stat = statSync(manifestPath, { bigint: true }); return { device: stat.dev, inode: stat.ino, @@ -99,7 +116,12 @@ function observePackageManifest(): PackageTreeObservation | null { } } -function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean { +/** The manifest path inside a package root. */ +export function packageManifestPathIn(root: string): string { + return join(root, "package.json"); +} + +export function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean { return left.device === right.device && left.inode === right.inode && left.contentTimeNs === right.contentTimeNs diff --git a/src/lib/package-tree-retarget.ts b/src/lib/package-tree-retarget.ts new file mode 100644 index 00000000000..75598cc5f42 --- /dev/null +++ b/src/lib/package-tree-retarget.ts @@ -0,0 +1,130 @@ +import { + packageManifestPathIn, + readPackageManifestVersionAt, + sameObservation, + type PackageTreeObservation, +} from "./package-tree-integrity"; + +/** + * The package a service launcher would start right now: its canonical root and the identity of + * the manifest inside it. Null when the launcher, its target or the manifest cannot be resolved. + */ +export interface LauncherTarget { + readonly root: string; + readonly manifest: PackageTreeObservation; +} + +export type ResolveLauncherTarget = () => LauncherTarget | null; + +export interface PackageTreeRetargetOptions { + /** How often the launcher is re-resolved. Detection must not depend on inbound requests. */ + pollIntervalMs?: number; + /** How long one complete target identity must hold before the restart is requested. */ + settleMs?: number; + now?: () => number; + /** Test seam; production uses an unref'd timer and returns its cancellation. */ + schedule?: (callback: () => void, delayMs: number) => () => void; + /** Test seam for the settled target's version; production reads its package manifest. */ + readVersion?: (root: string) => string | undefined; +} + +export interface PackageTreeRetargetWatch { + /** + * Version of the package the launcher now starts, once that target has held for the full + * settle interval and still resolves to the same identity. A fenced `/healthz` reports it + * when the running tree itself has gone, so `ocx restart` compares against what the + * supervisor will actually start. + */ + settledVersion(): string | undefined; + /** Stops polling and invalidates any pending restart request. */ + dispose(): void; +} + +const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_SETTLE_MS = 5_000; + +function sameTarget(left: LauncherTarget, right: LauncherTarget): boolean { + return left.root === right.root && sameObservation(left.manifest, right.manifest); +} + +/** + * Watches a version-manager launcher whose target can move to a different package tree while the + * running tree stays intact. + * + * The package-tree integrity fence only sees its own manifest. A manager that installs every + * version into its own directory and then repoints a floating link (mise's `latest`) never + * touches that manifest, so the fence neither restarts onto the new version nor recovers when + * the old version is later pruned from under it. This watch compares what the launcher resolves + * to with the running root instead. + * + * `onRetargeted` runs once, after one complete target identity (canonical root plus manifest + * identity) has held for `settleMs`. Any change of either part, an unresolvable launcher, or a + * return to the running root restarts the wait. A throwing handler is retried after another full + * interval. After the supervisor restarts through the launcher, the new process's running root + * is the target, so it never requests a second restart. + */ +export function createPackageTreeRetargetWatch( + runningRoot: string, + resolveTarget: ResolveLauncherTarget, + onRetargeted: () => void, + options: PackageTreeRetargetOptions = {}, +): PackageTreeRetargetWatch { + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS; + const now = options.now ?? Date.now; + const readVersion = options.readVersion ?? (root => readPackageManifestVersionAt(packageManifestPathIn(root))); + const schedule = options.schedule ?? ((callback, delayMs) => { + const timer = setTimeout(callback, delayMs); + timer.unref?.(); + return () => clearTimeout(timer); + }); + + let stopped = false; + let cancelPoll: (() => void) | null = null; + let candidate: LauncherTarget | null = null; + let candidateSince = 0; + let settled: LauncherTarget | null = null; + + const poll = (): void => { + cancelPoll = null; + if (stopped) return; + const target = resolveTarget(); + if (target === null || target.root === runningRoot) { + candidate = null; + settled = null; + } else if (candidate === null || !sameTarget(candidate, target)) { + candidate = target; + candidateSince = now(); + settled = null; + } else if (now() - candidateSince >= settleMs) { + settled = target; + try { + onRetargeted(); + stopped = true; + return; + } catch { + // Restart admission refused (for example, the service home changed owner). Keep the + // settled identity and ask again only after another full interval. + candidateSince = now(); + } + } + cancelPoll = schedule(poll, pollIntervalMs); + }; + + cancelPoll = schedule(poll, pollIntervalMs); + + return { + settledVersion: () => { + if (settled === null) return undefined; + const current = resolveTarget(); + if (current === null || !sameTarget(settled, current)) return undefined; + return readVersion(settled.root); + }, + dispose: () => { + stopped = true; + const cancel = cancelPoll; + cancelPoll = null; + cancel?.(); + }, + }; +} diff --git a/src/server/index/package-tree-guard.ts b/src/server/index/package-tree-guard.ts index f358738c5ce..b0ca93fe0e4 100644 --- a/src/server/index/package-tree-guard.ts +++ b/src/server/index/package-tree-guard.ts @@ -2,9 +2,11 @@ import { createRuntimePackageTreeIntegrityGuard, type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; +import { createPackageTreeRetargetWatch } from "../../lib/package-tree-retarget"; import { acceptSystemRestart } from "../management/system-restart"; import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight"; import { detectInstall } from "../../update/index"; +import { planMiseLauncherTargetWatch } from "../../update/mise-launcher-target"; import type { StartServerDeps } from "./startup-warnings"; // Production guard wiring for the package-tree integrity fence. Tests inject @@ -21,31 +23,43 @@ export function createPackageTreeIntegrityGuardForServer( } const acceptPackageTreeRestart = deps.acceptSystemRestart ?? acceptSystemRestart; let vetoAcceptedRestart: (() => void) | undefined; + // An out-of-band install replaced or retargeted the package this live process runs. Let the + // standard drain-and-restart path bring the new tree up instead of refusing traffic until a + // manual restart. acceptSystemRestart is idempotent and supervisor-aware. Throwing tells the + // caller to retry after its own debounce. + const restartOntoNewPackageTree = () => { + const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned(); + if (!beforeScheduledDrain()) throw new Error("service home ownership changed"); + acceptPackageTreeRestart(undefined, { + onAccepted: veto => { vetoAcceptedRestart = veto; }, + beforeScheduledDrain, + }); + }; const guard = createRuntimePackageTreeIntegrityGuard( deps.packageTreeInstaller ?? detectInstall(), deps.observePackageTree, undefined, - { - ...deps.packageTreeIntegrityOptions, - onReplaced: () => { - // An out-of-band install replaced the package under this live process. Serve - // the 503 for the triggering request, then let the standard drain-and-restart - // path bring the new tree up instead of refusing traffic until a manual - // restart. acceptSystemRestart is idempotent and supervisor-aware. - const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned(); - if (!beforeScheduledDrain()) throw new Error("service home ownership changed"); - acceptPackageTreeRestart(undefined, { - onAccepted: veto => { vetoAcceptedRestart = veto; }, - beforeScheduledDrain, - }); - }, - }, + { ...deps.packageTreeIntegrityOptions, onReplaced: restartOntoNewPackageTree }, ); + // mise installs each version beside the last and repoints a floating link, so the manifest + // above never changes on `mise upgrade`. The managed service follows its launcher instead. + const plan = deps.packageTreeLauncherTarget === undefined + ? planMiseLauncherTargetWatch() + : deps.packageTreeLauncherTarget; + const retarget = plan + ? createPackageTreeRetargetWatch( + plan.runningRoot, + plan.resolveTarget, + restartOntoNewPackageTree, + deps.packageTreeRetargetOptions, + ) + : null; return { status: () => guard.status(), - installedVersion: () => guard.installedVersion?.(), + installedVersion: () => guard.installedVersion?.() ?? retarget?.settledVersion(), dispose: () => { guard.dispose(); + retarget?.dispose(); vetoAcceptedRestart?.(); vetoAcceptedRestart = undefined; }, diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 2e395a688ea..5ebbb04805e 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -11,6 +11,8 @@ import type { PackageTreeIntegrityOptions, PackageTreeRuntimeInstall, } from "../../lib/package-tree-integrity"; +import type { PackageTreeRetargetOptions } from "../../lib/package-tree-retarget"; +import type { MiseLauncherTargetWatchPlan } from "../../update/mise-launcher-target"; import { consumeForInspection, relaySseWithHeartbeat, @@ -158,6 +160,10 @@ export interface StartServerDeps { packageTreeServiceChild?: () => boolean; /** Test-only: whether this service child still owns its service home. */ packageTreeServiceHomeOwned?: () => boolean; + /** Test-only launcher plan; production plans from the mise owner and service state. Null disables. */ + packageTreeLauncherTarget?: MiseLauncherTargetWatchPlan | null; + /** Test-only retarget-watch timing and version seams. */ + packageTreeRetargetOptions?: PackageTreeRetargetOptions; /** Test-only seam for observing quota-worker registration ownership. */ registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker; } diff --git a/src/update/mise-launcher-target.ts b/src/update/mise-launcher-target.ts new file mode 100644 index 00000000000..ec2596d34a2 --- /dev/null +++ b/src/update/mise-launcher-target.ts @@ -0,0 +1,100 @@ +import { realpathSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { + observePackageManifestAt, + packageManifestPathIn, + runningPackageRoot, +} from "../lib/package-tree-integrity"; +import type { LauncherTarget, ResolveLauncherTarget } from "../lib/package-tree-retarget"; +import { readServiceInstallState, SERVICE_MANAGED_ENV } from "../service/state"; +import { detectInstallOwnershipFromPath, type InstallOwnership } from "./install-detection.mjs"; + +const PACKAGE_DIR = join("@bitkyc08", "opencodex"); + +/** + * The package launcher mise's npm backend creates in `//node_modules/.bin`, + * beside the package it starts. This is the layout `detectInstallOwnershipFromPath` verifies as + * mise-owned; a launcher in any other layout is not followed. + */ +const LAUNCHER_BIN = join("node_modules", ".bin"); +const LAUNCHER_PACKAGE_DIR = join("node_modules", PACKAGE_DIR); + +export interface MiseLauncherTargetDeps { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + ownership?: () => InstallOwnership; + launcherPath?: () => string | undefined; + runningRoot?: () => string; + realpath?: (path: string) => string; +} + +export interface MiseLauncherTargetWatchPlan { + runningRoot: string; + resolveTarget: ResolveLauncherTarget; +} + +function canonical(path: string, realpath: (path: string) => string): string | null { + try { + return realpath(path); + } catch { + return null; + } +} + +/** + * Map a recorded service launcher to the package directory it starts, when it is one of mise's + * own package launchers for this tool. A mise shim (`/shims/ocx`) resolves to the mise + * binary and names no package, and anything outside `//` is not a launcher + * this install owns, so both are unsupported and return null. + */ +function launcherPackageEntry(launcherPath: string, toolRoot: string, realpath: (path: string) => string): string | null { + if (!isAbsolute(launcherPath)) return null; + const binDir = dirname(launcherPath); + const canonicalToolRoot = canonical(toolRoot, realpath); + if (canonicalToolRoot === null) return null; + if (!binDir.endsWith(`/${LAUNCHER_BIN}`)) return null; + const selectorDir = binDir.slice(0, binDir.length - LAUNCHER_BIN.length - 1); + if (canonical(dirname(selectorDir), realpath) !== canonicalToolRoot) return null; + return join(selectorDir, LAUNCHER_PACKAGE_DIR); +} + +/** + * Decide whether this process should watch its service launcher for a mise upgrade, and build the + * resolver when it should. + * + * Eligible only when every one of these holds: + * - Linux. launchd services always run pinned package paths (see the stable-launcher contract), + * and Windows services have no launcher, so neither can follow a moved `latest` link. + * - The process is the managed service job itself (`OCX_SERVICE_MANAGED=1`). `OCX_SERVICE=1` + * alone is also set on proxies `ocx claude`/`ocx opencode` spawn, and a foreground proxy must + * never restart itself because a service record exists. + * - The running package has a verified mise owner, and the recorded launcher is one of that + * tool's package launchers. + * - At boot, the launcher resolves to exactly the running package. That proves the supervisor + * started this process through it, so a restart through it converges instead of looping. + */ +export function planMiseLauncherTargetWatch(deps: MiseLauncherTargetDeps = {}): MiseLauncherTargetWatchPlan | null { + if ((deps.platform ?? process.platform) !== "linux") return null; + if ((deps.env ?? process.env)[SERVICE_MANAGED_ENV] !== "1") return null; + const realpath = deps.realpath ?? realpathSync; + const runningRoot = canonical((deps.runningRoot ?? runningPackageRoot)(), realpath); + if (runningRoot === null) return null; + const ownership = (deps.ownership ?? (() => detectInstallOwnershipFromPath(runningRoot)))(); + if (ownership.installer !== "mise" || !ownership.owner) return null; + const owner = ownership.owner; + const launcherPath = (deps.launcherPath ?? (() => readServiceInstallState()?.launcherPath))(); + if (!launcherPath) return null; + const entry = launcherPackageEntry(launcherPath, owner.toolRoot, realpath); + const toolRoot = canonical(owner.toolRoot, realpath); + if (entry === null || toolRoot === null) return null; + + const resolveTarget = (): LauncherTarget | null => { + const root = canonical(entry, realpath); + // A floating link repointed outside this tool's installs is not a mise upgrade of it. + if (root === null || !root.startsWith(`${toolRoot}/`)) return null; + const manifest = observePackageManifestAt(packageManifestPathIn(root)); + return manifest === null ? null : { root, manifest }; + }; + if (resolveTarget()?.root !== runningRoot) return null; + return { runningRoot, resolveTarget }; +} diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b5858dad9eb..ac414690dff 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -338,6 +338,22 @@ startup identity cancels the pending restart. Failed restart admission retries a bounded delay. Stopping the server before the accepted restart begins vetoes it, and a service child restarts only while it still owns the service home. Source checkouts and standalone binaries remain outside this fence. +mise installs every version in its own directory and repoints a floating link, so `mise upgrade` +never changes the manifest the fence watches: the proxy would keep serving the old version, and once +mise pruned it the fence would report the tree unreadable and refuse traffic without restarting. +`src/update/mise-launcher-target.ts` therefore plans a launcher watch, and +`src/lib/package-tree-retarget.ts` runs it, only for the managed Linux service +(`OCX_SERVICE_MANAGED=1`) of a verified mise owner whose recorded launcher is that tool's +`/node_modules/.bin/ocx`, and only when that launcher resolves to the running package at +boot. Shims, other layouts, launchd (which pins package paths) and foreground proxies are not +followed. The watch re-resolves the launcher on its own unref'd timer, so an idle service notices an +upgrade without a request. One complete target identity, canonical package root plus manifest +identity, must hold for the settle interval; a change to either, an unresolvable target, a target +outside the tool root, or a return to the running root restarts the wait. It then enters the same +restart handler as the fence without fencing requests, retries a refused admission after another +full interval, and reports the settled target's version as `installedVersion` when the fence has +none. The replacement boots from the target, so it never restarts again. + The fence withholds readiness, never identity (INV-FENCE-01). The fenced `/healthz` still answers a local attestation challenge and reports `restartCapability`, plus the `installedVersion` on disk once the replacement has held for the full stability interval (a readable manifest alone does not diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2c5b32d69b0..8b5986622fe 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -266,7 +266,7 @@ so an override can never shorten the budgets that prevent a duplicate proxy, and `src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the constants in child processes. -`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. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. 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. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `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. +`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. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. 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. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `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, and the managed Linux service additionally follows its mise package launcher onto an upgraded version ([package-tree integrity fence](docs-and-release.md#package-tree-integrity-fence)). ## Package cache refresh diff --git a/tests/ci-workflows/package-tree-retarget.test.ts b/tests/ci-workflows/package-tree-retarget.test.ts new file mode 100644 index 00000000000..25f154add30 --- /dev/null +++ b/tests/ci-workflows/package-tree-retarget.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import type { PackageTreeObservation } from "../../src/lib/package-tree-integrity"; +import { createPackageTreeRetargetWatch, type LauncherTarget } from "../../src/lib/package-tree-retarget"; +import { createPackageTreeIntegrityGuardForServer } from "../../src/server/index/package-tree-guard"; + +const RUNNING = "/mise/installs/opencodex/2.65.0/pkg"; +const NEXT = "/mise/installs/opencodex/2.66.0/pkg"; +const OTHER = "/mise/installs/opencodex/2.67.0/pkg"; +const manifest = (inode: bigint): PackageTreeObservation => ({ device: 1n, inode, contentTimeNs: 1n, size: 1n }); +const target = (root: string, inode = 1n): LauncherTarget => ({ root, manifest: manifest(inode) }); + +/** + * Manual clock and scheduler. Only `tick()` advances anything, so every case proves detection + * comes from the watch's own timer and never from a health or API request. + */ +function harness(initial: LauncherTarget | null = target(RUNNING), runningRoot = RUNNING) { + let clock = 0; + let pending: { callback: () => void; at: number } | null = null; + let current = initial; + let restarts = 0; + let refuse = 0; + const watch = createPackageTreeRetargetWatch(runningRoot, () => current, () => { + if (refuse > 0) { + refuse -= 1; + throw new Error("admission refused"); + } + restarts += 1; + }, { + pollIntervalMs: 1_000, + settleMs: 3_000, + now: () => clock, + schedule: (callback, delayMs) => { + const entry = { callback, at: clock + delayMs }; + pending = entry; + return () => { if (pending === entry) pending = null; }; + }, + readVersion: root => (root === NEXT ? "2.66.0" : root === OTHER ? "2.67.0" : undefined), + }); + return { + watch, + set: (next: LauncherTarget | null) => { current = next; }, + refuseNext: (count: number) => { refuse = count; }, + restarts: () => restarts, + polling: () => pending !== null, + tick: (times = 1) => { + for (let i = 0; i < times; i += 1) { + const entry = pending; + if (!entry) return; + pending = null; + clock = entry.at; + entry.callback(); + } + }, + }; +} + +describe("package-tree retarget watch", () => { + test("an idle service restarts once the launcher's new target has held for the settle interval", () => { + const h = harness(); + h.tick(2); + h.set(target(NEXT)); + h.tick(); // first sighting at t=3s + h.tick(2); // t=5s: held 2s + expect(h.restarts()).toBe(0); + h.tick(); // t=6s: held 3s + expect(h.restarts()).toBe(1); + expect(h.polling()).toBe(false); + }); + + test("a launcher that keeps resolving to the running package never restarts", () => { + const h = harness(); + h.tick(20); + expect(h.restarts()).toBe(0); + expect(h.polling()).toBe(true); + }); + + test("retargeting again during the wait restarts the full settle interval", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.set(target(OTHER)); + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + expect(h.watch.settledVersion()).toBe("2.67.0"); + }); + + test("a manifest change inside the same target root restarts the wait", () => { + const h = harness(); + h.set(target(NEXT, 1n)); + h.tick(3); + h.set(target(NEXT, 2n)); // the install rewrote package.json after the root appeared + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("reverting to the running package during the wait cancels the restart", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(2); + h.set(target(RUNNING)); + h.tick(10); + expect(h.restarts()).toBe(0); + expect(h.watch.settledVersion()).toBeUndefined(); + }); + + test("a target that briefly cannot be resolved needs a fresh full interval after it recovers", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.set(null); + h.tick(); + h.set(target(NEXT)); + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("a refused restart is asked again only after another full interval", () => { + const h = harness(); + h.refuseNext(1); + h.set(target(NEXT)); + h.tick(4); // refused at t=4s + expect(h.restarts()).toBe(0); + h.tick(2); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("the replacement process booted from the new target does not restart again", () => { + const h = harness(target(NEXT), NEXT); + h.tick(20); + expect(h.restarts()).toBe(0); + }); + + test("settledVersion reports the settled target only while it still resolves identically", () => { + const h = harness(); + expect(h.watch.settledVersion()).toBeUndefined(); + h.set(target(NEXT)); + h.tick(3); + expect(h.watch.settledVersion()).toBeUndefined(); + h.tick(); + expect(h.watch.settledVersion()).toBe("2.66.0"); + h.set(target(NEXT, 9n)); + expect(h.watch.settledVersion()).toBeUndefined(); + }); + + test("dispose stops polling and invalidates a pending restart", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.watch.dispose(); + expect(h.polling()).toBe(false); + h.tick(5); + expect(h.restarts()).toBe(0); + }); +}); + +describe("server package-tree guard with a launcher plan", () => { + function serverGuard(serviceChild: boolean, owned: boolean) { + let current: LauncherTarget | null = target(RUNNING); + let pending: (() => void) | null = null; + const accepted: string[] = []; + const guard = createPackageTreeIntegrityGuardForServer({ + packageTreeInstaller: "mise", + observePackageTree: () => manifest(1n), + packageTreeLauncherTarget: { runningRoot: RUNNING, resolveTarget: () => current }, + packageTreeRetargetOptions: { + pollIntervalMs: 1, + settleMs: 0, + schedule: callback => { pending = callback; return () => { pending = null; }; }, + readVersion: root => (root === NEXT ? "2.66.0" : undefined), + }, + acceptSystemRestart: (() => { + accepted.push("restart"); + return { accepted: true, alreadyDraining: false, activeTurnCount: 0, drainTimeoutMs: 0 }; + }) as never, + }, () => owned, () => serviceChild); + const tick = () => { const run = pending; pending = null; run?.(); }; + return { guard, accepted, tick, retarget: (next: LauncherTarget | null) => { current = next; } }; + } + + test("a mise upgrade restarts through the shared handler without fencing requests", () => { + const s = serverGuard(true, true); + s.retarget(target(NEXT)); + s.tick(); + s.tick(); + expect(s.accepted).toEqual(["restart"]); + expect(s.guard.status()).toEqual({ ok: true }); + expect(s.guard.installedVersion?.()).toBe("2.66.0"); + s.guard.dispose(); + }); + + test("a service child that no longer owns its home refuses and keeps waiting", () => { + const s = serverGuard(true, false); + s.retarget(target(NEXT)); + s.tick(); + s.tick(); + expect(s.accepted).toEqual([]); + s.guard.dispose(); + }); + + test("null plan keeps the fence-only behavior", () => { + const guard = createPackageTreeIntegrityGuardForServer({ + packageTreeInstaller: "mise", + observePackageTree: () => manifest(1n), + packageTreeLauncherTarget: null, + }, () => true, () => true); + expect(guard.status()).toEqual({ ok: true }); + expect(guard.installedVersion?.()).toBeUndefined(); + guard.dispose(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fdb563d16f1..4ead6adcb86 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1161,6 +1161,7 @@ "package-tree-fenced-restart.test.ts": "ci-workflows", "package-tree-integrity.test.ts": "ci-workflows", "package-tree-restart-ownership.test.ts": "ci-workflows", + "package-tree-retarget.test.ts": "ci-workflows", "parallel-tool-calls-optin.test.ts": "codex-integration", "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", @@ -1594,6 +1595,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.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/update/update-mise-launcher-target.test.ts b/tests/update/update-mise-launcher-target.test.ts new file mode 100644 index 00000000000..0d120e35036 --- /dev/null +++ b/tests/update/update-mise-launcher-target.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { planMiseLauncherTargetWatch, type MiseLauncherTargetDeps } from "../../src/update/mise-launcher-target"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let toolRoot = ""; + +/** One mise npm-backend install as aube lays it out: `/node_modules/.mise/@/...`. */ +function installVersion(version: string, base = toolRoot): string { + const store = join(base, version, "node_modules", ".mise", `@bitkyc08+opencodex@${version}`, "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(store, { recursive: true }); + writeFileSync(join(store, "package.json"), JSON.stringify({ name: "@bitkyc08/opencodex", version })); + const link = join(base, version, "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(join("..", ".mise", `@bitkyc08+opencodex@${version}`, "node_modules", "@bitkyc08", "opencodex"), link); + mkdirSync(join(base, version, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(base, version, "node_modules", ".bin", "ocx"), "#!/bin/sh\n", { mode: 0o755 }); + return realpathSync(store); +} + +function pointLatestAt(version: string, base = toolRoot): void { + rmSync(join(base, "latest"), { force: true }); + symlinkSync(`./${version}`, join(base, "latest")); +} + +const launcher = (base = toolRoot) => join(base, "latest", "node_modules", ".bin", "ocx"); + +function plan(overrides: MiseLauncherTargetDeps = {}, running = join(toolRoot, "2.65.0", "node_modules", "@bitkyc08", "opencodex")) { + return planMiseLauncherTargetWatch({ + platform: "linux", + env: { OCX_SERVICE_MANAGED: "1" }, + runningRoot: () => running, + launcherPath: () => launcher(), + ...overrides, + }); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-launcher-"))); + toolRoot = join(root, "installs", "opencodex"); + mkdirSync(toolRoot, { recursive: true }); + writeFileSync(join(toolRoot, ".mise.backend.toml"), 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n'); +}); + +afterEach(() => { + removeTreeWithRetry(root); +}); + +describe("mise launcher target plan", () => { + test("follows `latest` from the running version to the upgraded one", () => { + const running = installVersion("2.65.0"); + const next = installVersion("2.66.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + expect(watch?.runningRoot).toBe(running); + expect(watch?.resolveTarget()?.root).toBe(running); + pointLatestAt("2.66.0"); + expect(watch?.resolveTarget()?.root).toBe(next); + }); + + test("still resolves the new target after the running version is pruned", () => { + installVersion("2.65.0"); + const next = installVersion("2.66.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + pointLatestAt("2.66.0"); + removeTreeWithRetry(join(toolRoot, "2.65.0")); + expect(watch?.resolveTarget()?.root).toBe(next); + }); + + test("is disabled for npm's --prefix layout, which mise ownership detection does not verify", () => { + const pkg = join(toolRoot, "2.65.0", "lib", "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, "package.json"), "{}"); + mkdirSync(join(toolRoot, "2.65.0", "bin"), { recursive: true }); + writeFileSync(join(toolRoot, "2.65.0", "bin", "ocx"), "", { mode: 0o755 }); + pointLatestAt("2.65.0"); + expect(plan({ launcherPath: () => join(toolRoot, "latest", "bin", "ocx") }, pkg)).toBeNull(); + }); + + test("a target repointed outside the tool's installs is unresolvable", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + const elsewhere = join(root, "elsewhere"); + installVersion("9.9.9", elsewhere); + rmSync(join(toolRoot, "latest")); + symlinkSync(join(elsewhere, "9.9.9"), join(toolRoot, "latest")); + expect(watch?.resolveTarget()).toBeNull(); + }); + + test.each([ + ["macOS, whose launchd services run pinned package paths", { platform: "darwin" as const }], + ["Windows, whose services have no launcher", { platform: "win32" as const }], + ["a proxy carrying only OCX_SERVICE=1", { env: { OCX_SERVICE: "1" } }], + ["a foreground proxy with a service record on disk", { env: {} }], + ["no recorded launcher", { launcherPath: () => undefined }], + ["a non-mise install", { ownership: () => ({ installer: "npm" as const }) }], + ["unverifiable mise ownership", { ownership: () => ({ installer: "mise" as const, owner: null, error: "metadata_inconsistent" as const }) }], + ])("is disabled for %s", (_label, overrides) => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + expect(plan(overrides)).toBeNull(); + }); + + test("is disabled for a mise shim, which names no package", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const shims = join(root, "shims"); + mkdirSync(shims, { recursive: true }); + writeFileSync(join(root, "mise"), "", { mode: 0o755 }); + symlinkSync(join(root, "mise"), join(shims, "ocx")); + expect(plan({ launcherPath: () => join(shims, "ocx") })).toBeNull(); + }); + + test("is disabled for another tool's launcher", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const other = join(root, "installs", "other"); + installVersion("1.0.0", other); + pointLatestAt("1.0.0", other); + expect(plan({ launcherPath: () => launcher(other) })).toBeNull(); + }); + + test("is disabled when the launcher did not start this process", () => { + installVersion("2.65.0"); + installVersion("2.66.0"); + pointLatestAt("2.66.0"); + expect(plan()).toBeNull(); + }); +});