diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 59818de2f5..ef3aa80cd8 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * opencodex npm bin launcher. + * opencodex published-package bin launcher. * * The package source is TypeScript that runs on the Bun runtime. To let - * `npm install -g @bitkyc08/opencodex` work without a separately-installed Bun, + * global npm and pnpm installs of `@bitkyc08/opencodex` work without a separately-installed Bun, * we bundle the runtime via the `bun` npm dependency and exec it from this * Node shim. (Dev still runs `bun run src/cli/index.ts` directly via the shebang on - * src/cli/index.ts — only the published npm `bin` routes through here.) + * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; @@ -20,6 +20,14 @@ import { dirname, join, resolve } from "node:path"; 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 { + pnpmOwnerInvocation, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, +} from "../src/update/pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "../src/update/registry-integrity.mjs"; import { hasPendingTeardownIn } from "../src/config/pending-teardown-names.mjs"; import { npmCachePreflightFailureMessage, @@ -44,6 +52,7 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); +const installMethod = detectInstallFromPath(here, { exists: existsSync }); 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="; @@ -53,7 +62,7 @@ function isNodeModulesInstall() { } function isBunGlobalInstall() { - return /[\\/]\.bun[\\/]/.test(here); + return installMethod === "bun"; } function currentPackageVersion() { @@ -102,10 +111,9 @@ function historyRestoreIncomplete() { } } -function repairCodexShimIfNeeded() { +function repairCodexShimIfNeeded(launcherPath = fileURLToPath(import.meta.url)) { if (!shouldRepairCodexShim()) return; - const launcher = fileURLToPath(import.meta.url); - const res = spawnSync(process.execPath, [launcher, "codex-shim", "install"], { + const res = spawnSync(process.execPath, [launcherPath, "codex-shim", "install"], { stdio: "inherit", windowsHide: true, }); @@ -135,34 +143,116 @@ function runTrayLifecycle(launcher, action) { }); } +function shellQuote(value) { + if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function launcherStartHint(launcher, port) { + return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`; +} + function runNpmSelfUpdate() { + return runPackageManagerSelfUpdate("npm"); +} + +function runPnpmSelfUpdate() { + return runPackageManagerSelfUpdate("pnpm"); +} + +function runningPnpmShimPath() { + const invoked = process.argv[1]; + if (!invoked) return undefined; + const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) { + return undefined; + } + return resolve(invoked); +} + +function runPackageManagerSelfUpdate(manager) { const current = currentPackageVersion(); const tag = updateTag(current); - const latestInvocation = npmInvocation(["view", `${PKG}@${tag}`, "version"]); - const installInvocation = npmInvocation(["install", "-g", `${PKG}@${tag}`]); + let owner; + if (manager === "pnpm") { + const ownerResult = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath: resolve(here, ".."), + commandPaths: resolvePnpmCommands(), + runningShimPath: runningPnpmShimPath(), + runPnpm: (commandPath, args, capture = false) => { + const invocation = pnpmInvocationForPath(commandPath, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: capture ? "pipe" : "ignore", + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + ...invocation.options, + }); + }, + }); + if (!ownerResult.ok) { + console.error(`opencodex: ${ownerResult.reason}; aborting before stopping the proxy.`); + process.exit(1); + } + owner = ownerResult.owner; + } + const managerInvocation = args => manager === "pnpm" + ? pnpmOwnerInvocation(owner, args) + : npmInvocation(args); + const latestInvocation = managerInvocation(["view", `${PKG}@${tag}`, "version"]); + const installArgs = manager === "pnpm" + ? ["add", "-g", "--allow-build=bun", `${PKG}@${tag}`] + : ["install", "-g", `${PKG}@${tag}`]; + const installInvocation = managerInvocation(installArgs); if (!latestInvocation || !installInvocation) { - console.error("opencodex: could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy."); + console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`); process.exit(1); } const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, { encoding: "utf8", timeout: 12000, windowsHide: true, + ...(latestInvocation.env ? { env: latestInvocation.env } : {}), ...latestInvocation.options, }); - const latest = latestResult.status === 0 ? latestResult.stdout.trim() : ""; + const latest = latestResult.status === 0 && typeof latestResult.stdout === "string" ? latestResult.stdout.trim() : ""; - console.log(`opencodex v${current} (installed via npm, tag ${tag})`); + console.log(`opencodex v${current} (installed via ${manager}, tag ${tag})`); if (latest && latest === current) { console.log(`Already on the latest ${tag} version (v${latest}).`); process.exit(0); } - const cachePreflight = runNpmCachePreflight(); - if (!cachePreflight.ok) { - console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + const integrity = checkRegistryPackageIntegrity(PKG, latest || null, args => { + const invocation = managerInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + encoding: "utf8", + timeout: 12000, + windowsHide: true, + ...(invocation.env ? { env: invocation.env } : {}), + ...invocation.options, + }); + }); + if (integrity.ok === false) { + console.error(`opencodex: ${integrity.reason}; aborting before stopping the proxy.`); process.exit(1); } + if (integrity.ok === "skipped") { + console.warn(`opencodex: integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`); + } else { + console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`); + } + + if (manager === "npm") { + const cachePreflight = runNpmCachePreflight(); + if (!cachePreflight.ok) { + console.error(`opencodex: ${npmCachePreflightFailureMessage(cachePreflight.reason)}. Aborting before stopping the proxy.`); + process.exit(1); + } + } // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. @@ -177,15 +267,15 @@ function runNpmSelfUpdate() { * may be re-registered and require elevation. */ function serviceRefreshArgs() { - return [launcher, "service", "repair"]; + return [postUpdateLauncher, "service", "repair"]; } /** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */ function serviceInstallArgs() { try { const state = JSON.parse(readFileSync(serviceStatePath, "utf8")); - if (state.backend === "native") return [launcher, "service", "install", "--native"]; + if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"]; } catch { /* missing or corrupt — fall through to default */ } - return [launcher, "service", "install"]; + return [postUpdateLauncher, "service", "install"]; } /** * Structured "is a service actually registered?" answer. @@ -262,16 +352,23 @@ function runNpmSelfUpdate() { // get it from one place. const launcher = fileURLToPath(import.meta.url); + // The pnpm owner preflight has verified this package tree and global group. Keep that exact + // package path as the recovery starting point; a path returned by the pnpm transaction + // replaces it only after the new tree and shims have been verified. + let postUpdateLauncher = manager === "pnpm" && owner + ? join(owner.packagePath, "bin", "ocx.mjs") + : launcher; + let postUpdateLauncherUsable = true; function startProxyDirectly() { - if (!existsSync(launcher)) { + if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); return; } const env = { ...process.env }; delete env.OCX_SERVICE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); - const child = spawn(process.execPath, [launcher, "start", "--port", String(bakePort)], { + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { detached: true, stdio: "ignore", windowsHide: true, @@ -296,7 +393,7 @@ function runNpmSelfUpdate() { // diagnostic says the service is genuinely absent. Installing after ANY repair // failure would resurrect the elevation prompt this change exists to avoid, and // could re-register a service the user just uninstalled. - if (svc.status !== 0 && readServiceInstalledFromStatus(launcher) === false) { + if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { console.log("No registered service found — installing it instead."); svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true }); } @@ -305,7 +402,7 @@ function runNpmSelfUpdate() { // Exit 0 can still leave stale/missing assets that never bring the proxy // back — match the GUI/CLI fallthrough so /healthz is not left dead. try { - const st = spawnSync(process.execPath, [launcher, "status", "--json"], { + const st = spawnSync(process.execPath, [postUpdateLauncher, "status", "--json"], { encoding: "utf8", timeout: 20_000, windowsHide: true, @@ -365,6 +462,10 @@ function runNpmSelfUpdate() { existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); function recoverStoppedRuntimeAfterFailure() { + if (!postUpdateLauncherUsable) { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); + return; + } if (serviceWasInstalled) { console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); refreshBackgroundServiceOrStartDirect(); @@ -423,62 +524,105 @@ function runNpmSelfUpdate() { } } - // #1942/#1849: stage -> verify -> swap -> rollback instead of installing straight - // into the live tree. A failure at any point leaves either the old or the new tree - // complete — never a file-less skeleton. Falls back to the legacy in-place install - // only when the transactional module cannot run at all. - const packageDir = resolve(here, ".."); - console.log(`Updating${latest ? ` to v${latest}` : ""} (transactional)...`); + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a + // content-addressable store and generated global shims, so its path uses pnpm's own + // global update operation and verifies the active group instead of renaming files. + console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); let res; try { - const tx = transactionalNpmUpdate({ - packageDir, - pkgName: PKG, - targetVersion: latest || undefined, - tag, - runNpm: (args) => { - const invocation = npmInvocation(args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...invocation.options, - }); - }, - log: (line) => console.log(line), - }); - if (tx.ok) { - res = { status: 0 }; - } else if (tx.phase === "stage" || tx.phase === "verify") { - // Live tree untouched: report and stop. Nothing to roll back. - console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); - res = { status: 1 }; + if (manager === "npm") { + const packageDir = resolve(here, ".."); + const tx = transactionalNpmUpdate({ + packageDir, + pkgName: PKG, + targetVersion: latest || undefined, + tag, + runNpm: (args) => { + const invocation = npmInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: "inherit", + timeout: 180000, + windowsHide: true, + ...invocation.options, + }); + }, + log: (line) => console.log(line), + }); + postUpdateLauncherUsable = tx.ok + || tx.rolledBack === true + || ["stage", "verify", "swap-backup"].includes(tx.phase); + if (tx.ok) { + res = { status: 0 }; + } else if (tx.phase === "stage" || tx.phase === "verify") { + // Live tree untouched: report and stop. Nothing to roll back. + console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); + res = { status: 1 }; + } else { + console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); + res = { status: 1 }; + } } else { - console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); - res = { status: 1 }; + const update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner, + runningPackagePath: resolve(here, ".."), + runPnpm: (args, capture = false) => { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: capture ? "pipe" : "inherit", + encoding: "utf8", + timeout: 180000, + windowsHide: true, + env: invocation.env, + ...invocation.options, + }); + }, + log: line => console.log(line), + }); + if (update.ok) { + // pnpm switches the active global group and updates its shim. Continue recovery + // through that fresh package tree, not the old group whose launcher is still + // executing this update. + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + res = { status: 0 }; + } else { + console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + res = { status: 1 }; + } } } catch (error) { // An unexpected throw means we cannot prove the live tree is untouched, so the // legacy in-place install (which deletes live first) is exactly the wrong rescue — // it recreates the #1849 destruction path. Report and stop; the boot probe and the // recovery marker cover the swap-window states. - console.error(`opencodex: transactional update failed unexpectedly (${error?.message ?? error}). ` + - `The live install was not knowingly modified; run 'ocx update' again or reinstall with ` + - `npm install -g --allow-scripts=bun ${PKG}@${tag}.`); + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + // An unexpected exception leaves the active package path unproven for either manager. + // Do not run service/tray/proxy recovery through a possibly half-swapped tree. + postUpdateLauncherUsable = false; + console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + + `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); res = { status: 1 }; } if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); - repairCodexShimIfNeeded(); + repairCodexShimIfNeeded(postUpdateLauncher); if (trayBeforeUpdate.refreshAfterReplacement) { - const tray = spawnSync(process.execPath, [launcher, ...trayBeforeUpdate.installArgs], { + const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { stdio: "inherit", windowsHide: true, }); if (tray.status !== 0) { console.warn("opencodex: Windows tray refresh failed. Run: ocx tray install"); - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); } } // The stop above unloaded any managed service; refresh via the freshly-installed @@ -487,19 +631,22 @@ function runNpmSelfUpdate() { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); } else { - console.log("Restart the proxy: ocx start"); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); } - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); recoverStoppedRuntimeAfterFailure(); - console.error(`\nUpdate failed (npm exit ${res.status ?? "?"}). Try manually: npm install -g --allow-scripts=bun ${PKG}@${tag}`); + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + console.error(`\nUpdate failed (${manager} exit ${res.status ?? "?"}). Try manually: ${manual}`); process.exit(1); } function bunBinDir() { // Resolve the `bun` dependency's directory without hardcoding the platform - // package — npm's os/cpu/libc resolution already picked the right @oven/bun-*. + // package — the package manager's os/cpu/libc resolution already picked the right @oven/bun-*. return dirname(require.resolve("bun/package.json")); } @@ -511,7 +658,7 @@ const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; function findBunBinary(bunDir) { - // The npm `bun` package ships the binary as bin/bun.exe on every platform; + // The bundled `bun` package ships the binary as bin/bun.exe on every platform; // probe bin/bun too for forward compatibility. for (const name of ["bun.exe", "bun"]) { const p = join(bunDir, "bin", name); @@ -521,12 +668,15 @@ function findBunBinary(bunDir) { } function fail(msg) { + const reinstall = installMethod === "pnpm" + ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" + : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; console.error( `opencodex: ${msg}\n` + "The bundled Bun runtime could not be prepared. This usually means the\n" + - "install skipped lifecycle scripts (e.g. npm blocked bun's postinstall\n" + - "under allowScripts) or optional dependencies. Reinstall with:\n" + - " npm install -g --allow-scripts=bun @bitkyc08/opencodex\n" + + "install skipped lifecycle scripts (for example npm blocked bun's postinstall\n" + + "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" + + ` ${reinstall}\n` + "(use sudo if the original install used sudo; without --ignore-scripts\n" + "and without --omit=optional / optional=false)" ); @@ -534,7 +684,7 @@ function fail(msg) { } function resolveBun({ allowInstall = true } = {}) { - // Keep direct npm-launcher starts aligned with durable service/shim installs: + // Keep direct package-launcher starts aligned with durable service/shim installs: // a valid explicit runtime must win even when the bundled dependency exists. const override = process.env[BUN_OVERRIDE_ENV]?.trim(); if (override) { @@ -566,7 +716,7 @@ function resolveBun({ allowInstall = true } = {}) { return { path: bin, source: "bundled" }; } -// `ocx update --help` prints usage and exits WITHOUT side effects. The npm launcher +// `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher // intercepts `update` before the Bun CLI starts, so the help short-circuit must live // here too — otherwise --help runs the real self-update, stops the proxy, and drops // in-flight routed streams (issue #168). @@ -584,13 +734,14 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { } if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { - runNpmSelfUpdate(); + if (installMethod === "npm") runNpmSelfUpdate(); + if (installMethod === "pnpm") runPnpmSelfUpdate(); } // #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a // backup sibling and a broken live tree. Restore before anything tries to run from the // broken tree; reap stale backups once the live tree verifies healthy. -if (!codexCliUpdateInspection && isNodeModulesInstall() && !isBunGlobalInstall()) { +if (!codexCliUpdateInspection && installMethod === "npm" && isNodeModulesInstall() && !isBunGlobalInstall()) { try { const probe = bootRestoreProbe(resolve(here, "..")); if (probe.action === "restored") { diff --git a/devlog/_plan/260911_l4_service_cli/000_packet.md b/devlog/_plan/260911_l4_service_cli/000_packet.md new file mode 100644 index 0000000000..86dc8351a2 --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/000_packet.md @@ -0,0 +1,107 @@ +# Dispatch packet — L4 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L4 — service, update, CLI, and connected client + +Worktree `~/.codex/worktrees/260911-l4/opencodex`, branch `codex/260911-l4-service-cli`. + +Owned: directories `src/update/`, `src/cli/`, `src/client/`; files `bin/ocx.mjs`, `src/cli.ts`, +`src/service.ts`, `src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, +`src/lib/package-tree-integrity.ts`, `src/lib/process-control.ts`, `src/codex/catalog/effort.ts`, +`src/codex/cli-install-provenance.ts`, `docs-site/src/content/docs/getting-started/installation.md`. + +Your stack is #4202 → #4169 → #4207. **#4204 was removed from the round** by the feasibility audit: +binding the clamp to the Desktop runtime needs `codex/runtime.ts:573`, `catalog/bundled.ts:239`, and +`catalog/sync.ts:1945`, because the catalog probes one selected runtime and no caller passes a +consumer identity. Resolving a catalog per consumer is a design decision this round does not make. + +1. **#4202 — global pnpm installations cannot self-update.** Carry PR #4203 by `oliver-mee` (open + **draft**, `CHANGES_REQUESTED`, 36 files). **Decision: the keep-set is exactly** `bin/ocx.mjs`, + `src/cli.ts`, `src/cli/launcher-context.ts`, `src/config/pending-teardown.ts`, + `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, `src/service.ts`, every file under + `src/update/`, the tests `tests/ci-workflows/install-scripts.test.ts`, + `tests/cli/ocx-launcher-runtime.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, + `tests/update/update-badge.test.ts`, `tests/update/update-job.test.ts`, + `tests/update/update-pnpm.test.ts`, `tests/update/update-stop-first.test.ts`, the two test-layout + maps, and `docs-site/src/content/docs/getting-started/installation.md`. **Drop** `README.md`, + `structure/01_runtime.md`, `structure/06_docs-and-release.md`, + `docs-site/src/content/docs/getting-started/for-agents.md`, and + `docs-site/src/content/docs/reference/cli/lifecycle.md`. +2. **#4169 — every stop refusal is reported as a `CODEX_HOME` ownership mismatch,** hiding + `respawnable_service`. Carry PR #4170 by `yeongjunyoo` (open **draft**, `REVIEW_REQUIRED`); it + touches `src/cli/index.ts` and `src/lib/process-control.ts`, both yours, plus its two tests + `tests/lib/process-control-graceful.test.ts` and `tests/providers/xai/grok-lifecycle.test.ts`, + which you keep. +3. **#4204 — Windows: a stale persisted CLI 0.135.0 strips max/ultra while Desktop runs 0.153.4.** + The clamp is `src/codex/catalog/effort.ts:441`. #4178 by `luvs01` is open, not a draft, full CI + green, and owns `src/codex/cli-install-provenance.ts`: if it lands first, rebase onto it; + otherwise keep out of that file and say so. +4. **#4207 — the connected catalog reports success while the local Codex CLI rejects unsupported + reasoning levels.** Same clamp as #4204, which is why both are here; client side is + `src/client/hub-client.ts:145`, `src/client/connect.ts:542`, `src/cli/connect.ts:187`. + **Decision: fail closed — block local readiness rather than reporting success** when the + projection is not compatible with the local client. + diff --git a/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md b/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md new file mode 100644 index 0000000000..2853da3578 --- /dev/null +++ b/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md @@ -0,0 +1,88 @@ +# wp1 — #4202: global pnpm installations cannot self-update + +Work-phase 1 of the L4 lane. Base: `origin/dev` after #4226 and #4227 landed. Carried source: +PR #4203 by `oliver-mee`, head `e74c6e54d`, two commits on base `f94dd88f1`. + +## What the issue asks for + +`ocx update` on a pnpm global installation forwards npm-only flags (`--allow-scripts=bun`, +`--no-audit`, `--no-fund`) to pnpm, which rejects them. The failure lands **after** the proxy has +already been stopped. #4202 asks for either a pnpm-native global update path or a safe actionable +error raised before the proxy is stopped. + +## Keep-set, verbatim from the packet + +31 of the carry's 36 files. Kept: `bin/ocx.mjs`, `src/cli.ts`, `src/cli/launcher-context.ts`, +`src/config/pending-teardown.ts`, `src/lib/bun-runtime.ts`, `src/lib/package-tree-integrity.ts`, +`src/service.ts`, every file under `src/update/`, the seven carried tests, the two test-layout maps, +and `docs-site/src/content/docs/getting-started/installation.md`. + +Dropped, because L4 does not own them: `README.md`, `structure/01_runtime.md`, +`structure/06_docs-and-release.md`, `docs-site/src/content/docs/getting-started/for-agents.md`, +`docs-site/src/content/docs/reference/cli/lifecycle.md`. + +`git diff f94dd88f1..e74c6e54d` restricted to the keep-set is 3676 lines and +`git apply --check` reports no conflict against the rebased branch: no keep-set path moved on +`dev` between the carry's base and the current tip. + +## The blocking finding this work-phase must fold in + +`Ingwannu` (repository owner) requested changes on #4203: + +> In src/update/transactional-install.mjs, verifyInstallTree now delegates to +> dependencyPackageDir/createRequire.resolve. That resolution can find dependencies in ancestor +> node_modules outside the candidate package tree. […] A candidate missing its own bundled Bun or +> sentinel dependency must not pass merely because an ancestor installation supplies one; otherwise +> staging/boot recovery can call a non-self-contained candidate healthy and discard or replace the +> known-good copy. + +The finding is structural, not stylistic. In the carry both exported verifiers are the same +function: `verifyInstallTree` and `verifyPnpmInstallTree` each call +`verifyInstallTreeWithDependencyRoot`, which resolves `bun` and the sentinel deps through +`createRequire(...).resolve`. Node's resolution walks the ancestor directory chain, so for a global +npm layout a candidate at `/lib/node_modules/@bitkyc08/opencodex` can satisfy its bun +requirement from `/lib/node_modules/bun`, which belongs to a different package. + +Three decisions consume that boolean, all on the npm path: + +- `transactional-install.mjs:198` accepts the staged tree (D2, before the swap). +- `transactional-install.mjs:241` re-verifies the live tree after the swap and decides rollback. +- `transactional-install.mjs:121`, inside `bootRestoreProbe`, decides that the live tree is healthy + and **reaps every backup**, which is the only known-good copy. + +So an over-permissive verdict is not cosmetic: it can accept a stage that cannot start, then delete +the backup that would have recovered it. + +## Plan + +1. Apply the keep-set diff unchanged. +2. Split the verifier in `src/update/transactional-install.mjs` into two real implementations that + share the manifest checks but not the dependency-resolution policy: + - `verifyInstallTree` (npm, and every caller above) returns to the strict pre-carry rule: a + sentinel dependency counts only at `/node_modules//package.json`, and the + bun size gate reads only `/node_modules/bun`. Ancestor resolution cannot satisfy it. + - `verifyPnpmInstallTree` keeps resolver-based discovery, because pnpm legitimately exposes + dependencies through a virtual store, a package-root symlink, or a hoisted group root, but adds + the ownership check the review asked for: the resolved dependency must live under a dependency + root that this package instance owns — its own `node_modules`, its realpath's `node_modules`, + or the `node_modules` that encloses the package when that root carries pnpm's own metadata + (`.pnpm` or `.modules.yaml`). An ancestor root with no pnpm evidence is refused. +3. Regression test at `tests/update/update-tree-ownership.test.ts`, registered in + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`, covering the + three cases the review named: a candidate missing its own bun with an unrelated ancestor bun + present, a truncated candidate bun with an intact ancestor bun, a legitimate pnpm virtual-store + and hoisted layout, and the `bootRestoreProbe` decision — the probe must restore the backup + rather than reap it when the live tree is not self-contained. +4. Commit with a `Co-authored-by` trailer for `oliver-mee`, push with `--no-verify`, open the PR + against `dev` with `Closes #4202`. + +## Diff level + +`src/update/transactional-install.mjs` ~60 lines changed on top of the carry; one new test file; +two one-line map registrations. Everything else is the carry verbatim. + +## Not run + +`bun test`, `bun run test`, `bun run test:changed`, `bun run typecheck`, `bun run build:gui` and +`bun install` are NOT RUN by operator instruction. Hosted CI on the exact pushed head is the only +product evidence this round accepts. diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 7c68e1b840..4ef61c5566 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -11,7 +11,7 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod | Requirement | Why | | --- | --- | -| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically on `npm install` — you do **not** need to install Bun yourself. | +| **[Node](https://nodejs.org) ≥ 18** | `ocx` runs on the Bun runtime, but the runtime is bundled automatically by the npm or pnpm install — you do **not** need to install Bun yourself. | | **[OpenAI Codex](https://openai.com/codex)** (CLI, App, or SDK) | The client opencodex sits in front of. opencodex writes to `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`). | | A provider account or API key | Anthropic, xAI, Kimi, Ollama Cloud, OpenRouter, an OpenAI-compatible endpoint, or your ChatGPT login. | @@ -21,6 +21,12 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod npm install -g @bitkyc08/opencodex ``` +With pnpm 10.4 or later: + +```bash +pnpm add -g --allow-build=bun @bitkyc08/opencodex +``` + :::note[npm blocked the bun postinstall?] Recent npm versions may block bun's postinstall script (`npm warn install-scripts ... blocked because they are not covered by allowScripts`), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..135aeaba5c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1232,10 +1232,12 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", + "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", diff --git a/src/cli.ts b/src/cli.ts index 2f060ab6aa..a852d7c80f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,8 @@ // // Before the src/ restructure the CLI lived at src/cli.ts, and durable launchers // (codex shim wrappers, installed service definitions) baked that absolute path -// into their command lines. Users who upgrade in place with a bare -// `npm install -g @bitkyc08/opencodex` (instead of `ocx update`, which repairs the +// into their command lines. Users who upgrade in place with a bare package-manager +// install (`npm install -g` or `pnpm add -g`) (instead of `ocx update`, which repairs the // shim/service) would otherwise be stranded on a dead path. Keep this stub for at // least one release cycle after the restructure ships. import "./cli/index.ts"; diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts index 091e46d3a9..5541ba7472 100644 --- a/src/cli/launcher-context.ts +++ b/src/cli/launcher-context.ts @@ -1,5 +1,5 @@ /** - * Trusted facts captured by the plain-Node npm launcher before Bun auto-loads + * Trusted facts captured by the plain-Node package launcher before Bun auto-loads * project dotenv files. The random proof travels in argv while the context * travels in the environment, so a project `.env` cannot forge the pair during * an ordinary `ocx ...` invocation. diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts index 31082b0fbb..bfab1cf757 100644 --- a/src/config/pending-teardown.ts +++ b/src/config/pending-teardown.ts @@ -175,7 +175,7 @@ export function listPendingTeardowns(): OutstandingTeardown[] { } const out: OutstandingTeardown[] = []; for (const name of names) { - // One naming rule, shared with the npm launcher: the two lanes drifting apart is + // One naming rule, shared with the package launcher: the two lanes drifting apart is // exactly how the Node updater stopped seeing receipts at all. if (!isPendingTeardownFileName(name)) continue; const nonce = pendingTeardownNonceFromFileName(name)!; diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index b8a09149a8..e5c7ed31d3 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -176,7 +176,7 @@ export function durableBunRuntime(): DurableBunRuntime { /** * Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and * the Codex auto-start shim). Prefer the bundled binary — it lives under the - * npm global prefix and survives across `ocx update` — and fall back to the + * manager-owned global package directory and survives across `ocx update` — and fall back to the * current runtime, which is Bun when launched normally. */ export function durableBunPath(): string { diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 443a164519..9b7d1e8429 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -16,7 +16,7 @@ export interface PackageTreeIntegrityGuard { } type ObservePackageTree = () => PackageTreeObservation | null; -type PackageTreeRuntimeInstall = "bun" | "npm" | "source"; +type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source"; const packageManifestUrl = new URL("../../package.json", import.meta.url); diff --git a/src/service.ts b/src/service.ts index 15a8876ba3..12d20fa0bb 100644 --- a/src/service.ts +++ b/src/service.ts @@ -71,7 +71,7 @@ const TASK = "opencodex-proxy"; export type ServiceBackend = "scheduler" | "native"; function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { - // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than + // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. // diff --git a/src/update/badge.ts b/src/update/badge.ts index fa4433dda9..0c373c1b71 100644 --- a/src/update/badge.ts +++ b/src/update/badge.ts @@ -1,13 +1,14 @@ /** * Cached "is an update available?" answer for the GUI sidebar badge. * - * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a + * `/api/update/check` spawns the installing manager's `view` command on every call + * (~1s, network-bound), so a * sidebar that polls it would spawn a process per tick on every page of the GUI. * The badge instead READS the 20h version cache the CLI update prompt already * maintains (`~/.opencodex/version.json`). * * This is deliberately read-only: it must never trigger a registry refresh. The GUI - * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view` + * polls it, so a refresh-on-read would let repeated polls launch repeated manager `view` * helpers with no coalescing. Cache warming stays with `ocx start` * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit * `/api/update/check` the user reaches by clicking the sidebar update button. diff --git a/src/update/index.ts b/src/update/index.ts index 1fd5fb099c..dccc63a288 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -3,13 +3,23 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs"; import { proxyIdentityAt } from "../server/proxy-liveness"; import { probeProxyLiveness } from "./proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "./stop-decision.mjs"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { getConfigDir, loadConfig } from "../config"; import { readPid, readRuntimePort } from "../config/process-state"; import { pendingTeardownOutstanding } from "../config/pending-teardown"; import { npmInvocation } from "./npm-invocation.mjs"; +import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; +import { detectInstallFromPath } from "./install-detection.mjs"; +import { + pnpmOwnerInvocation, + readPnpmGlobalPackage, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, +} from "./pnpm-global-install.mjs"; +import type { PnpmGlobalOwner, PnpmGlobalOwnerResult } from "./pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "./registry-integrity.mjs"; import { npmCachePreflightFailureMessage, runNpmCachePreflight, @@ -35,13 +45,101 @@ 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" | "source"; +export type Installer = "bun" | "npm" | "pnpm" | "source"; export type Channel = "latest" | "preview"; /** Infer how opencodex is installed from the running module's path. */ export function detectInstall(): Installer { - if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install - return HERE.includes(".bun") ? "bun" : "npm"; + return detectInstallFromPath(HERE, { exists: existsSync }); +} + +function packageRoot(): string { + return resolve(HERE, "..", ".."); +} + +function runningPnpmShimPath(): string | undefined { + const invoked = process.argv[1]; + if (!invoked) return undefined; + const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) { + return undefined; + } + return resolve(invoked); +} + +function runPnpmCandidate( + commandPath: string, + args: readonly string[], + capture = false, +): { status: number | null; stdout?: string | null; stderr?: string | null } { + const invocation = pnpmInvocationForPath(commandPath, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + stdio: capture ? "pipe" : "ignore", + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + ...invocation.options, + }); +} + +/** Resolve the exact pnpm executable/group/bin that own this package. */ +export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult { + return resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath: packageRoot(), + commandPaths: resolvePnpmCommands(), + runningShimPath: runningPnpmShimPath(), + runPnpm: runPnpmCandidate, + }); +} + +function ownerPnpmTarget( + owner: PnpmGlobalOwner, + args: readonly string[], +): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean }; env: Record } | null { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return null; + return { + bin: invocation.file, + args: invocation.args, + options: invocation.options, + env: invocation.env, + }; +} + +function runOwnedPnpm( + owner: PnpmGlobalOwner, + args: readonly string[], + capture: boolean, + stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit", +): { status: number | null; stdout?: string | null; stderr?: string | null } { + const target = ownerPnpmTarget(owner, args); + if (!target) return { status: 1 }; + return spawnSync(target.bin, target.args, { + stdio, + encoding: "utf8", + timeout: 180_000, + windowsHide: true, + env: target.env, + ...target.options, + }); +} + +/** Re-read the owning group's active package and return its verified launcher. */ +export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null { + const active = readPnpmGlobalPackage( + PKG, + (args, capture = false) => runOwnedPnpm(owner, args, capture), + undefined, + { + owner, + expectedGlobalDir: owner.globalDir, + expectedGlobalRoot: owner.globalRoot, + globalBinDir: owner.globalBinDir, + }, + ); + return active.ok ? join(active.path, "bin", "ocx.mjs") : null; } export function currentVersion(): string { @@ -63,14 +161,57 @@ export function updateTag(current: string): Channel { return defaultUpdateTag(current); } -function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null { +type SpawnTarget = { + bin: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; + env?: Record; +}; + +function npmSpawnTarget(args: readonly string[]): SpawnTarget | null { const invocation = npmInvocation(args); if (!invocation) return null; return { bin: invocation.file, args: invocation.args, options: invocation.options }; } -function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null { +function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): SpawnTarget | null { + if (owner) { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return null; + return { + bin: invocation.file, + args: invocation.args, + options: invocation.options, + env: invocation.env, + }; + } + const invocation = pnpmInvocation(args); + if (!invocation) return null; + return { bin: invocation.file, args: invocation.args, options: invocation.options }; +} + +function registrySpawnTarget( + installer: Installer, + args: readonly string[], + owner?: PnpmGlobalOwner, +): SpawnTarget | null { + // A pnpm command without an owner would silently fall back to the first PATH + // candidate. That is unsafe when multiple PNPM_HOME installations expose the + // same version, so registry queries use the same hard binding as mutation. + return installer === "pnpm" + ? owner ? pnpmSpawnTarget(args, owner) : null + : npmSpawnTarget(args); +} + +function selectedPnpmOwner(owner?: PnpmGlobalOwner): PnpmGlobalOwner | undefined { + if (owner) return owner; + const result = resolveCurrentPnpmGlobalOwner(); + return result.ok ? result.owner : undefined; +} + +function updateSpawnTarget(bin: string, args: readonly string[]): SpawnTarget | null { if (bin === "npm") return npmSpawnTarget(args); + if (bin === "pnpm") return pnpmSpawnTarget(args); if (process.platform === "win32" && bin === "bun") { return { bin: process.execPath, args: [...args], options: {} }; } @@ -95,28 +236,46 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr); } -/** Latest published version from the registry (best-effort; null if npm isn't available). */ -export function latestVersion(tag: string): string | null { - const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]); - if (!npm) return null; - const r = spawnSync(npm.bin, npm.args, { +function shellQuote(value: string): string { + if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function launcherStartHint(launcher: string, port: number): string { + return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`; +} + +/** Latest published version from the registry (best-effort; null if the manager isn't available). */ +export function latestVersion( + tag: string, + installer: Installer = detectInstall(), + owner?: PnpmGlobalOwner, +): string | null { + const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined; + if (installer === "pnpm" && !resolvedOwner) return null; + const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner); + if (!manager) return null; + const r = spawnSync(manager.bin, manager.args, { encoding: "utf8", timeout: 12000, windowsHide: true, - ...npm.options, + ...(manager.env ? { env: manager.env } : {}), + ...manager.options, }); - return r.status === 0 ? (r.stdout.trim() || null) : null; + return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null; } /** 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[] } { - const bin = installer === "bun" ? "bun" : "npm"; // 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; - const args = installer === "bun" - ? ["add", "-g", `${PKG}@${target}`] - : ["install", "-g", `${PKG}@${target}`]; + if (installer === "bun") return { bin: "bun", args: ["add", "-g", `${PKG}@${target}`] }; + if (installer === "pnpm") { + return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] }; + } + const bin = "npm"; + const args = ["install", "-g", `${PKG}@${target}`]; return { bin, args }; } @@ -138,26 +297,33 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer export function checkUpdatePackageIntegrity( version: string | null, spawn: typeof spawnSync = spawnSync, + installer: Installer = detectInstall(), + owner?: PnpmGlobalOwner, ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } { - if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" }; - const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]); - if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" }; - const r = spawn( - npm.bin, - npm.args, - { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options }, - ); - // status !== 0 covers nonzero exits AND timeouts (status === null). - if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` }; - const tokens = (r.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); - const match = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token)); - if (!match) return { ok: false, reason: `registry returned no sha512 integrity for ${PKG}@${version}` }; - return { ok: true, integrity: match }; + const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined; + if (installer === "pnpm" && !resolvedOwner) { + return { ok: false, reason: "could not identify pnpm's owning global installation" }; + } + const manager = registrySpawnTarget(installer, ["view", `${PKG}@${version}`, "dist.integrity"], resolvedOwner); + if (!manager) return { ok: "skipped", reason: `${installer} executable was not found on a trusted PATH entry` }; + const result = checkRegistryPackageIntegrity(PKG, version, args => { + const target = registrySpawnTarget(installer, args, resolvedOwner); + if (!target) return { status: 1 }; + return spawn(target.bin, target.args, { + encoding: "utf8", + timeout: 12000, + windowsHide: true, + ...(target.env ? { env: target.env } : {}), + ...target.options, + }); + }); + return result; } /** - * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated - * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary. + * `ocx update` fallback for source checkouts and Bun global installs. npm and pnpm global installs + * are updated in the Node bin launcher before Bun starts, so Windows does not replace the running + * Bun binary. */ export async function runUpdate(): Promise { const installer = detectInstall(); @@ -170,7 +336,13 @@ export async function runUpdate(): Promise { return; } - const latest = latestVersion(tag); + const ownerResult = installer === "pnpm" ? resolveCurrentPnpmGlobalOwner() : undefined; + if (installer === "pnpm" && (!ownerResult || !ownerResult.ok)) { + console.error(`⚠️ ${ownerResult?.reason ?? "Could not identify pnpm's owning global installation"}. Aborting before stopping the proxy.`); + process.exit(1); + } + const owner = ownerResult?.ok ? ownerResult.owner : undefined; + const latest = latestVersion(tag, installer, owner); if (latest && latest === current) { console.log(`Already on the latest ${tag} version (v${latest}).`); return; @@ -178,7 +350,7 @@ export async function runUpdate(): Promise { // Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an // anomalous registry entry aborts without unloading the running service. - const integrity = checkUpdatePackageIntegrity(latest); + const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner); if (integrity.ok === false) { console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`); process.exit(1); @@ -198,9 +370,11 @@ export async function runUpdate(): Promise { } const { bin, args: cmdArgs } = updateCommand(installer, tag, latest); - const target = updateSpawnTarget(bin, cmdArgs); + const target = installer === "pnpm" && owner + ? pnpmSpawnTarget(cmdArgs, owner) + : updateSpawnTarget(bin, cmdArgs); if (!target) { - console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy."); + console.error(`⚠️ Could not resolve ${bin} from a trusted absolute PATH entry; aborting before stopping the proxy.`); process.exit(1); } @@ -257,7 +431,9 @@ export async function runUpdate(): Promise { // shared client config still points at a proxy that is gone; installing over that // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). + let stopAttempted = false; if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) { + stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), { @@ -266,7 +442,7 @@ export async function runUpdate(): Promise { windowsHide: true, }); if (stopStdio === "pipe") logSpawnOutput("", stop); - // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about + // One decision, shared with the package launcher (#3008). The two lanes disagreeing about // the same situation is how this shipped fixed on one side only. Absent PID and runtime // files are weak evidence - a crashed-but-listening proxy leaves none - so the captured // endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike. @@ -310,35 +486,96 @@ export async function runUpdate(): Promise { console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); const installStdio = updateChildStdio(); - const r = spawnSync(target.bin, target.args, { - stdio: installStdio, - encoding: installStdio === "pipe" ? "utf8" : undefined, - timeout: 180000, - windowsHide: true, - ...target.options, - }); + // Every post-update action below receives this path. For pnpm it is replaced only + // by a path returned after tree+shim verification; on rollback, activePath is + // likewise returned only after the old group has been verified again. + let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs"); + // The pnpm owner preflight has verified this package tree and global group. Keep that exact + // package path as the recovery starting point; the path returned by the update transaction + // replaces it only after post-update tree+shim verification succeeds. + if (installer === "pnpm" && owner) { + postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs"); + } + let postUpdateLauncherUsable = true; + let r: { + status: number | null; + signal?: NodeJS.Signals | null; + stdout?: string | Buffer | null; + stderr?: string | Buffer | null; + }; + if (installer === "pnpm") { + let update: ReturnType; + try { + update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner: owner!, + runningPackagePath: packageRoot(), + runPnpm: (args, capture = false) => runOwnedPnpm( + owner!, + args, + capture, + capture ? "pipe" : installStdio, + ), + log: line => console.log(line), + }); + } catch { + // A thrown verifier/runner error means the active group is unknown. Mark the + // launcher unusable and let the failure lane report a manual recovery path. + update = { + ok: false, + phase: "rollback", + rolledBack: false, + error: "pnpm update failed unexpectedly; active package could not be verified", + }; + } + if (update.ok) { + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + postUpdateLauncherUsable = true; + r = { status: 0, signal: null, stdout: "", stderr: "" }; + } else { + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + console.error(`⚠️ ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + r = { status: 1, signal: null, stdout: "", stderr: "" }; + } + } else { + r = spawnSync(target.bin, target.args, { + stdio: installStdio, + encoding: installStdio === "pipe" ? "utf8" : undefined, + timeout: 180000, + windowsHide: true, + ...target.options, + }); + } if (installStdio === "pipe") logSpawnOutput("", r); if (r.status === 0) { console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); - // Re-bake the bundled Bun path into the Codex autostart shim on every - // platform when one is installed (refresh-only; never installs fresh). + // Re-enter through the verified active package launcher. This keeps the Codex + // shim, tray, service and proxy recovery paths on the same package/group that + // pnpm selected, including when the update changed the global link target. try { - const { isCodexShimInstalled, installCodexShim } = await import("../codex/shim"); + const { isCodexShimInstalled } = await import("../codex/shim"); if (isCodexShimInstalled()) { - const result = installCodexShim(); - if (result.installed) console.log(`🔧 ${result.message}`); + const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], { + stdio: "inherit", + windowsHide: true, + }); + if (shim.status !== 0) console.warn("⚠️ Shim repair skipped: run 'ocx codex-shim install'."); } - } catch (e) { - console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`); + } catch { + console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'."); } if (trayWasInstalled) { - const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); - const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true }); + const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; + const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true }); if (tray.status === 0) { console.log("🔧 Refreshed Windows tray startup paths."); } else { console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'."); - if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true }); + if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } } // The stop above unloaded any managed service; repair it with the NEW files @@ -362,7 +599,7 @@ export async function runUpdate(): Promise { process.env.OCX_BAKE_PORT = String(capturedListen.port); try { const svcStdio = updateChildStdio(); - const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), { + const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { stdio: svcStdio, encoding: svcStdio === "pipe" ? "utf8" : undefined, windowsHide: true, @@ -407,7 +644,7 @@ export async function runUpdate(): Promise { : " Run 'ocx service repair' to refresh the background service and see why it failed."); const env = { ...process.env }; delete env.OCX_SERVICE; - const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), { + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { detached: true, stdio: "ignore", windowsHide: true, @@ -422,14 +659,30 @@ export async function runUpdate(): Promise { else process.env.OCX_BAKE_PORT = prevBake; } } else { - console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* keep the primary update failure */ } + if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { + spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); + } + if (stopAttempted && serviceWasInstalled && postUpdateLauncherUsable) { + const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], { + stdio: "inherit", + windowsHide: true, + }); + if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'."); + } else if (stopAttempted && postUpdateLauncherUsable) { + const env = { ...process.env }; + delete env.OCX_SERVICE; + const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: withProcessRuntimeProvenance(env), + }); + child.unref(); + } else if (stopAttempted) { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); } console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`); process.exit(1); diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts new file mode 100644 index 0000000000..88f68ba731 --- /dev/null +++ b/src/update/install-detection.d.mts @@ -0,0 +1,6 @@ +export type DetectedInstall = "bun" | "npm" | "pnpm" | "source"; + +export declare function detectInstallFromPath( + packagePath: string, + deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string }, +): DetectedInstall; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs new file mode 100644 index 0000000000..e21064c9b5 --- /dev/null +++ b/src/update/install-detection.mjs @@ -0,0 +1,73 @@ +import { realpathSync } from "node:fs"; + +/** + * Infer the package manager from the path of the running package. + * + * PATH is deliberately not consulted here. A machine can have npm, pnpm, and Bun + * installed at the same time; the package layout is the evidence of which manager owns + * the files that the updater must change. The legacy `global/` spelling is only + * accepted when the adjacent filesystem metadata also looks like a pnpm global group. + * + * The real path is considered in addition to the spelling visible to the module loader. + * This matters when Node is launched with preserved symlinks: a pnpm package can be + * exposed through an npm-looking prefix while its target is still under pnpm's global + * virtual store. + */ +export function detectInstallFromPath(packagePath, deps = {}) { + const exists = deps.exists; + const candidates = [String(packagePath)]; + try { + const resolved = (deps.realpath ?? realpathSync)(String(packagePath)); + if (resolved && !candidates.includes(resolved)) candidates.push(resolved); + } catch { + // Synthetic paths in source-level checks, and a partially removed install, have no + // realpath. The lexical path still carries the evidence when it is available. + } + + let sawNodeModules = false; + for (const candidate of candidates) { + const detected = detectInstallCandidate(candidate, exists); + if (detected === "pnpm" || detected === "bun") return detected; + if (detected === "npm") sawNodeModules = true; + } + return sawNodeModules ? "npm" : "source"; +} + +function detectInstallCandidate(packagePath, exists) { + const normalized = String(packagePath).replaceAll("\\", "/"); + 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 + // from a Windows API or a user-created junction. + if (!segments.some(segment => segment.toLowerCase() === "node_modules")) return "source"; + + // Strong signatures survive normal symlink resolution: the v10 isolated virtual store + // and v11 global virtual store are both manager-owned paths. Do not classify an arbitrary + // npm prefix such as `/opt/global/v11` from its directory name alone. + if ( + /(?:^|\/)node_modules\/\.pnpm(?:\/|$)/i.test(normalized) + || /(?:^|\/)store\/v\d+\/links(?:\/|$)/i.test(normalized) + ) return "pnpm"; + + // `--preserve-symlinks` can leave a v10/v11 group path visible. Corroborate it with the + // group's virtual store or the v11 global store before selecting pnpm. + const globalMatch = normalized.match(/^(.*\/global\/(?:v)?\d+)(?:\/[^/]+)*\/node_modules(?:\/|$)/i); + if (globalMatch && exists) { + const globalRoot = globalMatch[1]; + const groupRoot = normalized.slice(0, normalized.toLowerCase().indexOf("/node_modules")); + if ( + exists(`${groupRoot}/node_modules/.pnpm`) + || exists(`${groupRoot}/node_modules/.modules.yaml`) + || exists(`${groupRoot}/node_modules/.pnpm/lock.yaml`) + || exists(`${globalRoot}/store`) + || exists(`${globalRoot}/pnpm-lock.yaml`) + ) return "pnpm"; + } + + // Bun's global layout is specifically `.bun/install/global/node_modules`. A bare `.bun` + // directory is not enough: npm projects can quite legitimately live under a dot-directory + // with that name, especially on Windows where package paths are often user-selected. + if (/(?:^|\/)\.bun\/install\/global\/node_modules(?:\/|$)/i.test(normalized)) return "bun"; + + return "npm"; +} diff --git a/src/update/job.ts b/src/update/job.ts index 75a55b40b3..b7e51ff8c5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -37,7 +37,10 @@ import { latestVersion, updateCommand, updateCommandStr, + resolveCurrentPnpmGlobalOwner, + resolvePnpmActiveLauncher, } from "./index"; +import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; @@ -132,6 +135,10 @@ function nodeBin(): string { return process.platform === "win32" ? "node.exe" : "node"; } +function usesNodeLauncher(installer: Installer): boolean { + return installer === "npm" || installer === "pnpm"; +} + /** * Strict bind script: exit 0 only after listen+close. Any listen error (including * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start` @@ -436,11 +443,13 @@ export function updateExecutionCommand( launcher = packageLauncherPath(), resolvedVersion?: string | null, ): { bin: string; args: string[]; display: string } { - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const bin = nodeBin(); const args = [launcher, "update", "--tag", channel]; // The Node launcher self-update re-resolves the tag at its own time — a residual // divergence window this path cannot close (documented, not claimed immutable). + // Both npm and pnpm use it so package files are never replaced by the running Bun + // process; the launcher then selects the manager-native update implementation. return { bin, args, display: formatCommand(bin, args) }; } if (installer === "bun") { @@ -467,7 +476,7 @@ export function restartCommand( // Default to the in-place refresh: `install` always registers, while repair reuses a healthy // Windows scheduler definition and re-registers only when the live definition is stale. const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs; - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const bin = nodeBin(); const args = svcArgs; return { mode, bin, args, display: formatCommand(bin, args) }; @@ -920,8 +929,9 @@ function spawnDetachedStart( job: UpdateJobState, installer: Installer, port?: number, + launcher = packageLauncherPath(), ): ChildProcess { - const cmd = restartCommand(false, installer, packageLauncherPath(), port); + const cmd = restartCommand(false, installer, launcher, port); const env = { ...process.env }; delete env.OCX_SERVICE; updateJob(job, {}, `$ ${cmd.display}`); @@ -961,7 +971,7 @@ function spawnDetachedStart( return child; } -/** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */ +/** Identity snapshot used to prove a package-manager self-update replaced the pre-update process. */ export interface RestartProxyIdentity { pid: number | null; version?: string; @@ -970,7 +980,9 @@ export interface RestartProxyIdentity { /** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */ export interface RestartIo { waitForPort?: typeof reclaimListenPort; - spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void; + spawnStart?: (job: UpdateJobState, installer: Installer, port?: number, launcher?: string) => void; + /** The package launcher verified after a pnpm group switch or rollback. */ + packageLauncherPathFn?: () => string; serviceInstalledFn?: () => boolean; /** * After a service reinstall exits 0, only trust the service path when this is true. @@ -1090,13 +1102,14 @@ async function restartAfterUpdate( svcArgs = serviceReinstallArgs(); } catch { /* fallback to default service install */ } } - const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs); + const launcher = io.packageLauncherPathFn?.() ?? packageLauncherPath(); + const cmd = restartCommand(serviceInstalled, job.installer, launcher, port, svcArgs); const waitFn = io.waitForPort ?? reclaimListenPort; const listPids = io.listListenPidsFn ?? listListenPids; const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity; const aliveFn = io.isAliveFn ?? isProcessAlive; // Pre-update PID plus any ocx still LISTENing on the captured port. After a - // stop-first npm self-update Windows often leaves a respawned bun/node child + // stop-first package-manager self-update Windows often leaves a respawned bun/node child // that is not the captured PID; treating it as protected blocks reclaim and // the direct-start fallback never binds. const reclaimKillAllowlist = (): number[] => { @@ -1294,7 +1307,7 @@ async function restartAfterUpdate( // Injected spawnStart keeps unit tests deterministic (one call). Production path // retries on missing /healthz after prepare + ghost-LISTEN clear. if (io.spawnStart) { - io.spawnStart(job, job.installer, port); + io.spawnStart(job, job.installer, port, launcher); return; } const sleep = io.sleepMs ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); @@ -1352,7 +1365,7 @@ async function restartAfterUpdate( ); continue; } - lastChild = spawnDetachedStart(job, job.installer, port); + lastChild = spawnDetachedStart(job, job.installer, port, launcher); const healthDeadline = Date.now() + perAttemptHealthMs; while (Date.now() < healthDeadline) { if (await probe(port, hostname)) return; @@ -1422,11 +1435,18 @@ export function restartAfterUpdateForTests( return restartAfterUpdate(job, captured, io); } -function restartFailureHint(port: number): string { +function restartFailureHint(port: number, installer: Installer): string { + const reinstall = installer === "pnpm" + ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" + : installer === "bun" + ? "bun add -g @bitkyc08/opencodex" + : installer === "source" + ? "git pull && bun install" + : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; return `Update installed, but the restarted proxy did not stay healthy on port ${port}. ` + `Try 'ocx start --port ${port}'. ` + "If the update log shows bun postinstall or EPERM warnings, " - + "reinstall with 'npm install -g --allow-scripts=bun @bitkyc08/opencodex'."; + + `reinstall with '${reinstall}'.`; } type AwaitHealthyResult = @@ -1517,7 +1537,7 @@ async function confirmRestartedProxy( status: "failed", restarted: false, error, - }, restartFailureHint(port)); + }, restartFailureHint(port, job.installer)); return false; } @@ -1559,7 +1579,7 @@ async function defaultProbeProxyIdentity( * when the pre-update PID was captured, and/or /healthz reporting the job's target * version when PID evidence is unavailable. */ -export function npmSelfUpdateRestartEvidence( +export function packageManagerSelfUpdateRestartEvidence( job: Pick, captured: { oldPid?: number }, identity: RestartProxyIdentity | null, @@ -1601,21 +1621,25 @@ export function npmSelfUpdateRestartEvidence( return { ok: false, reason: "no pre-update PID capture and no expected-version match" }; } +// Kept as a compatibility export for callers and existing integrations that used the +// original npm-specific name before pnpm became a supported package-manager path. +export const npmSelfUpdateRestartEvidence = packageManagerSelfUpdateRestartEvidence; + /** * Post-install restart for the GUI worker. * - * npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls / + * npm and pnpm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls / * starts the service (or falls back to a direct start). A second `service install` here * calls `stopWindows()` on that healthy listener, then often fails elevation from the * non-interactive worker — leaving the captured port (default 10100) dead until a manual - * restart. Prefer confirming the npm self-update's own restart first; only re-run restart + * restart. Prefer confirming the package-manager self-update's own restart first; only re-run restart * when that probe fails. Bun/source installs still always take the explicit restart path. * - * Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs` + * Probe-first applies only to service-managed npm/pnpm installs: without a service, `ocx.mjs` * only prints `ocx start` and never brings the proxy back, so waiting would always burn * the full health timeout. Skipping also requires update-correlated evidence (PID change * and/or target version) so a surviving pre-update process cannot look like success. - * After an explicit npm restart the same evidence is required again — health alone is + * After an explicit package-manager restart the same evidence is required again — health alone is * not enough when a no-op restart or failed port reclaim leaves the old proxy up. * * Browser-dashboard update recovery must not require a viable Background Service: when @@ -1628,10 +1652,10 @@ export async function finishGuiUpdateRestart( installer: Installer, io: RestartIo = {}, ): Promise { - if (installer === "npm") { + if (usesNodeLauncher(installer)) { const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)(); if (serviceInstalled) { - // Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the + // Stop-first package-manager update leaves a dead PID's LISTEN row. Polling /healthz for the // full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind. // If nothing live owns the port, skip straight to explicit restart. A failed // listener scan must not look like "no listeners" — fall back to /healthz. @@ -1646,13 +1670,13 @@ export async function finishGuiUpdateRestart( ? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid)) : null; if (liveListeners !== null && liveListeners.length === 0) { - updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart..."); + updateJob(job, {}, `${installer} self-update did not leave a live listener; performing explicit restart...`); } else { if (!scan.ok) { updateJob( job, {}, - "Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...", + `Listener scan inconclusive after ${installer} self-update; probing /healthz before deciding on explicit restart...`, ); } const already = await awaitRestartedProxyHealthy(job, captured, io); @@ -1661,42 +1685,42 @@ export async function finishGuiUpdateRestart( captured.port, captured.hostname, ); - const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); + const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity); if (evidence.ok) { updateJob( job, {}, - `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`, + `Proxy already healthy on ${captured.hostname}:${captured.port} after ${installer} self-update (${evidence.detail}); skipping redundant restart.`, ); return true; } updateJob( job, {}, - `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, + `${installer} self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`, ); } else { - updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart..."); + updateJob(job, {}, `${installer} self-update did not leave a healthy proxy; performing explicit restart...`); } } } } const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate; await restartFn(job, captured, io); - if (installer !== "npm") { + if (!usesNodeLauncher(installer)) { // Bun/source: health alone remains enough unless a richer identity probe is supplied. if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io); } - return confirmNpmExplicitRestart(job, captured, io); + return confirmPackageManagerExplicitRestart(job, captured, io); } /** - * After an explicit npm (or identity-aware) restart, require update-correlated + * After an explicit package-manager (or identity-aware) restart, require update-correlated * evidence — not merely a healthy OpenCodex listener. A no-op restart or a * failed port reclaim can leave the pre-update process on the captured port; * `confirmRestartedProxy` alone would treat that as success. */ -async function confirmNpmExplicitRestart( +async function confirmPackageManagerExplicitRestart( job: UpdateJobState, captured: { port: number; hostname: string; oldPid?: number }, io: RestartIo = {}, @@ -1712,7 +1736,7 @@ async function confirmNpmExplicitRestart( status: "failed", restarted: false, error, - }, restartFailureHint(port)); + }, restartFailureHint(port, job.installer)); return false; } @@ -1720,13 +1744,13 @@ async function confirmNpmExplicitRestart( captured.port, captured.hostname, ); - const evidence = npmSelfUpdateRestartEvidence(job, captured, identity); + const evidence = packageManagerSelfUpdateRestartEvidence(job, captured, identity); if (!evidence.ok) { updateJob(job, { status: "failed", restarted: false, error: `proxy restart did not show update-correlated identity (${evidence.reason})`, - }, restartFailureHint(captured.port)); + }, restartFailureHint(captured.port, job.installer)); return false; } @@ -1748,10 +1772,16 @@ async function confirmNpmExplicitRestart( */ export interface GuiUpdateWorkerIo { cachePreflightFn?: () => { ok: boolean; reason: string }; - /** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */ + /** Force the resolved update target. A source checkout otherwise aborts before the update branch. */ checkForUpdateFn?: (channel: Channel) => ReturnType; /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */ integrityFn?: (version: string | null) => ReturnType; + /** Override pnpm ownership discovery for an isolated worker test. */ + resolvePnpmOwnerFn?: () => ReturnType; + /** Resolve the launcher only after the pnpm update has verified its active group and shims. */ + resolvePnpmActiveLauncherFn?: (owner: PnpmGlobalOwner) => string | null; + /** Restart seams used by focused worker tests; the verified launcher is always injected. */ + restartIo?: RestartIo; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1786,6 +1816,9 @@ export async function runGuiUpdateWorker( }; let trayWasInstalled = false; let trayWasRunning = false; + let activeLauncher = packageLauncherPath(); + let activeLauncherVerified = true; + let pnpmOwner: PnpmGlobalOwner | undefined; if (!job) { job = { id: jobId, @@ -1809,10 +1842,18 @@ export async function runGuiUpdateWorker( throw new Error(check.reason ?? "No update is available"); } + if (check.installer === "pnpm") { + const ownerResult = (io.resolvePnpmOwnerFn ?? resolveCurrentPnpmGlobalOwner)(); + if (!ownerResult.ok) throw new Error(`Could not identify pnpm's owning global installation: ${ownerResult.reason}`); + pnpmOwner = ownerResult.owner; + } + // Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry // metadata for a resolved version fails the job BEFORE anything is spawned or the // proxy is stopped; transient registry failure degrades to a logged skip. - const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion); + const integrity = io.integrityFn + ? io.integrityFn(check.latestVersion) + : checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner); if (integrity.ok === false) { updateJob(job, { status: "failed", error: integrity.reason }); return; @@ -1865,15 +1906,19 @@ export async function runGuiUpdateWorker( /* [Decision Log] - 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다. - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. - - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. + - 선택 근거: 현재 CLI의 package-manager self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. */ const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); if (result.status !== 0) { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* retain the primary update failure */ } + if (check.installer === "pnpm") { + const verifiedLauncher = pnpmOwner + ? (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner) + : null; + if (verifiedLauncher) activeLauncher = verifiedLauncher; + else activeLauncherVerified = false; + } + if (trayWasRunning && activeLauncherVerified) { + runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } updateJob(job, { status: "failed", @@ -1884,29 +1929,36 @@ export async function runGuiUpdateWorker( return; } + if (check.installer === "pnpm") { + const verifiedLauncher = (io.resolvePnpmActiveLauncherFn ?? resolvePnpmActiveLauncher)(pnpmOwner!); + if (!verifiedLauncher) throw new Error("pnpm update succeeded but no verified active launcher remains"); + activeLauncher = verifiedLauncher; + } + if (trayWasInstalled) { - const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); - const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000); + const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; + const tray = runLoggedCommand(job, process.execPath, [activeLauncher, ...trayArgs], 20_000); if (tray.status !== 0) { updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'."); - if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000); + if (trayWasRunning) runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } } if (restart) { job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - if (!(await finishGuiUpdateRestart(job, captured, check.installer))) return; + if (!(await finishGuiUpdateRestart(job, captured, check.installer, { + ...io.restartIo, + packageLauncherPathFn: () => activeLauncher, + }))) return; updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); return; } updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version."); } catch (err) { - if (trayWasRunning) { - try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); - } catch { /* retain the primary worker failure */ } + if (check.installer === "pnpm") activeLauncherVerified = false; + if (trayWasRunning && activeLauncherVerified) { + runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } updateJob(job, { status: "failed", diff --git a/src/update/pnpm-global-install.d.mts b/src/update/pnpm-global-install.d.mts new file mode 100644 index 0000000000..5dc5b6004a --- /dev/null +++ b/src/update/pnpm-global-install.d.mts @@ -0,0 +1,144 @@ +export interface PnpmRunResult { + status: number | null; + stdout?: string | Uint8Array | null; + stderr?: string | Uint8Array | null; +} + +export type RunPnpm = (args: readonly string[], capture?: boolean) => PnpmRunResult; +export type RunPnpmCandidate = ( + commandPath: string, + args: readonly string[], + capture?: boolean, +) => PnpmRunResult; + +export interface PnpmGlobalOwner { + commandPath: string; + packagePath: string; + /** Base passed to pnpm's --global-dir; pnpm creates the versioned group below it. */ + globalDir: string; + /** Actual versioned global group reported by pnpm root/list. */ + globalRoot: string; + globalBinDir: string; + version?: string; +} + +export type PnpmGlobalOwnerResult = + | { ok: true; owner: PnpmGlobalOwner } + | { ok: false; reason: string }; + +export type PnpmVerificationResult = { ok: boolean; reason?: string }; + +export declare const PNPM_BUILD_APPROVAL: string; + +export declare function pnpmGlobalCommandArgs( + args: readonly string[], + owner: PnpmGlobalOwner, +): string[]; + +export declare function pnpmOwnerEnvironment( + owner: PnpmGlobalOwner, + env?: Record, + platform?: NodeJS.Platform, +): Record; + +export interface PnpmOwnerInvocation { + file: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; + env: Record; +} + +export declare function pnpmOwnerInvocation( + owner: PnpmGlobalOwner, + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, +): PnpmOwnerInvocation | null; + +export declare function verifyPnpmGlobalShims( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + exists?: (path: string) => boolean, +): PnpmVerificationResult; + +export type PnpmGlobalPackage = + | { + ok: true; + version: string; + path: string; + globalDir?: string; + globalRoot?: string; + globalBinDir?: string; + } + | { ok: false; reason: string }; + +export interface PnpmGlobalReadConstraints { + owner?: PnpmGlobalOwner; + expectedPackagePath?: string; + expectedGlobalDir?: string; + expectedGlobalRoot?: string; + globalBinDir?: string; + /** Set false only for pre-update owner binding; post-update reads verify shims by default. */ + checkShims?: boolean; + platform?: NodeJS.Platform; + verifyShims?: ( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + ) => PnpmVerificationResult; +} + +export declare function readPnpmGlobalPackage( + packageName: string, + runPnpm: RunPnpm, + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult, + constraints?: PnpmGlobalReadConstraints, +): PnpmGlobalPackage; + +export declare function resolvePnpmGlobalOwner(options: { + packageName: string; + packagePath: string; + commandPaths: readonly string[]; + runningShimPath?: string; + runPnpm: RunPnpmCandidate; + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult; + platform?: NodeJS.Platform; +}): PnpmGlobalOwnerResult; + +export type PnpmGlobalUpdateResult = + | { + ok: true; + phase: "done"; + version: string; + path: string; + globalDir: string; + globalBinDir: string; + } + | { + ok: false; + phase: "preflight" | "install" | "rollback"; + rolledBack?: boolean; + activePath?: string; + globalDir?: string; + globalBinDir?: string; + error: string; + }; + +export declare function runPnpmGlobalUpdate(options: { + packageName: string; + currentVersion?: string; + targetVersion?: string; + tag: string; + owner: PnpmGlobalOwner; + runningPackagePath?: string; + runPnpm: RunPnpm; + verify?: (packageDir: string, expectedVersion?: string) => PnpmVerificationResult; + verifyShims?: ( + packageDir: string, + globalBinDir: string, + platform?: NodeJS.Platform, + ) => PnpmVerificationResult; + platform?: NodeJS.Platform; + log?: (line: string) => void; +}): PnpmGlobalUpdateResult; diff --git a/src/update/pnpm-global-install.mjs b/src/update/pnpm-global-install.mjs new file mode 100644 index 0000000000..a038a374f0 --- /dev/null +++ b/src/update/pnpm-global-install.mjs @@ -0,0 +1,591 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path"; +import { pnpmInvocationForPath } from "./pnpm-invocation.mjs"; +import { verifyPnpmInstallTree } from "./transactional-install.mjs"; + +export const PNPM_BUILD_APPROVAL = "--allow-build=bun"; + +function outputText(value) { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + return ""; +} + +function pathKey(value, platform = process.platform) { + const raw = String(value).replaceAll("\\", "/"); + const normalise = path => { + const result = path.replaceAll("\\", "/"); + return platform === "win32" ? result.toLowerCase() : result; + }; + try { + return normalise(realpathSync.native(raw)); + } catch { + return normalise(resolve(raw)); + } +} + +function lexicalPathKey(value, platform = process.platform) { + const result = resolve(String(value).replaceAll("\\", "/")).replaceAll("\\", "/"); + return platform === "win32" ? result.toLowerCase() : result; +} + +function samePath(left, right, platform = process.platform) { + return pathKey(left, platform) === pathKey(right, platform) + || lexicalPathKey(left, platform) === lexicalPathKey(right, platform); +} + +function singleAbsoluteCommandPath(runPnpm, commandPath, args, platform = process.platform) { + let result; + try { + result = runPnpm(commandPath, args, true); + } catch { + return null; + } + if (result?.status !== 0) return null; + const lines = outputText(result.stdout).split(/\r?\n/).map(line => line.trim()).filter(Boolean); + if (lines.length !== 1 || /^(?:undefined|null)$/i.test(lines[0])) return null; + return isAbsolutePath(lines[0], platform) ? lines[0] : null; +} + +function configPathValue(runPnpm, commandPath, key, platform = process.platform) { + return singleAbsoluteCommandPath(runPnpm, commandPath, ["config", "get", key], platform); +} + +function absoluteConfigPath(value, platform = process.platform) { + return typeof value === "string" && isAbsolutePath(value, platform) ? value : null; +} + +function isAbsolutePath(value, platform = process.platform) { + if (typeof value !== "string") return false; + return isAbsolute(value) || ( + platform === "win32" + && /^(?:[A-Za-z]:[\\/]|\\\\|\/)/.test(String(value)) + ); +} + +function pathDirname(value, platform = process.platform) { + return platform === "win32" ? win32.dirname(String(value)) : dirname(value); +} + +function pathBasename(value, platform = process.platform) { + return platform === "win32" ? win32.basename(String(value)) : basename(value); +} + +/** Normalize pnpm list/root output to the versioned global group, not its node_modules root. */ +function normaliseGlobalRoot(value, platform = process.platform) { + if (!isAbsolutePath(value, platform)) return null; + const trimmed = String(value).replace(/[\\/]+$/, ""); + return pathBasename(trimmed, platform).toLowerCase() === "node_modules" + ? pathDirname(trimmed, platform) + : trimmed; +} + +/** pnpm's --global-dir is the base; pnpm appends the major-version group below it. */ +function globalDirFromRoot(globalRoot, platform = process.platform) { + const separator = platform === "win32" ? /[\\/]/ : /\//; + const match = String(globalRoot).match(new RegExp(`^(.*)${separator.source}(?:v)?\\d+$`, "i")); + return match?.[1] || globalRoot; +} + +function globalRootMatchesDir(globalRoot, globalDir, platform = process.platform) { + return samePath(globalRoot, globalDir, platform) + || samePath(pathDirname(globalRoot, platform), globalDir, platform); +} + +/** Resolve manager-owned global paths, including pnpm defaults that config get leaves undefined. */ +function resolveGlobalPaths(commandPath, runPnpm, listedRoot, platform = process.platform) { + const listedGlobalRoot = normaliseGlobalRoot(listedRoot, platform); + const commandGlobalRoot = normaliseGlobalRoot( + singleAbsoluteCommandPath(runPnpm, commandPath, ["root", "-g"], platform), + platform, + ); + if (listedGlobalRoot && commandGlobalRoot && !samePath(listedGlobalRoot, commandGlobalRoot, platform)) { + return null; + } + const globalRoot = commandGlobalRoot ?? listedGlobalRoot; + if (!globalRoot) return null; + + const configuredGlobalDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-dir", platform), platform); + const globalDir = configuredGlobalDir ?? globalDirFromRoot(globalRoot, platform); + const configuredGlobalBinDir = absoluteConfigPath(configPathValue(runPnpm, commandPath, "global-bin-dir", platform), platform); + const globalBinDir = configuredGlobalBinDir + ?? absoluteConfigPath(singleAbsoluteCommandPath(runPnpm, commandPath, ["bin", "-g"], platform), platform); + if (!globalDir || !globalBinDir || !globalRootMatchesDir(globalRoot, globalDir, platform)) return null; + return { globalDir, globalRoot, globalBinDir }; +} + +function packageEntryFromRoot(root, packageName) { + const maps = [root?.dependencies, root?.devDependencies, root?.optionalDependencies]; + for (const dependencies of maps) { + if (!dependencies || typeof dependencies !== "object") continue; + const direct = dependencies[packageName]; + if (direct && typeof direct === "object") return direct; + } + return null; +} + +function inspectListOutput(stdout, packageName) { + let roots; + try { + roots = JSON.parse(outputText(stdout)); + } catch { + return null; + } + if (!Array.isArray(roots)) return null; + for (const root of roots) { + const entry = packageEntryFromRoot(root, packageName); + if (entry) return { root, entry }; + } + return null; +} + +function ownerGlobalArgs(args, owner) { + const input = [...args]; + if (!owner) return input; + const command = input[0]; + if (!["add", "install", "update", "list", "remove", "uninstall"].includes(command)) return input; + const rest = []; + for (let index = 1; index < input.length; index += 1) { + const arg = input[index]; + // The owner is authoritative. Remove an accidentally inherited/supplied value rather + // than relying on duplicate pnpm flags having stable precedence across pnpm 10/11. + if (arg === "--global-dir" || arg.startsWith("--global-dir=")) { + if (arg === "--global-dir") index += 1; + continue; + } + if (arg === "--config.global-bin-dir" || arg.startsWith("--config.global-bin-dir=")) { + if (arg === "--config.global-bin-dir") index += 1; + continue; + } + rest.push(arg); + } + return [ + command, + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + ...rest, + ]; +} + +/** Add the selected pnpm global group and bin directory to a command's config. */ +export function pnpmGlobalCommandArgs(args, owner) { + return ownerGlobalArgs(args, owner); +} + +/** + * Add the owning global bin directory to PATH for commands such as pnpm's bin + * validation. Do not replace PATH: registry auth and the selected pnpm executable + * can depend on the rest of the inherited environment. + */ +export function pnpmOwnerEnvironment(owner, env = process.env, platform = process.platform) { + const key = platform === "win32" && env.Path !== undefined && env.PATH === undefined ? "Path" : "PATH"; + const delimiter = platform === "win32" ? ";" : ":"; + const existing = env[key] ?? env.PATH ?? env.Path ?? ""; + const entries = String(existing).split(delimiter).filter(Boolean); + if (!entries.some(entry => samePath(entry, owner.globalBinDir, platform))) entries.unshift(owner.globalBinDir); + return { ...env, [key]: entries.join(delimiter) }; +} + +/** Build an invocation for the already-selected pnpm executable and global group. */ +export function pnpmOwnerInvocation(owner, args, platform = process.platform, env = process.env) { + const ownerEnv = pnpmOwnerEnvironment(owner, env, platform); + const invocation = pnpmInvocationForPath( + owner.commandPath, + ownerGlobalArgs(args, owner), + platform, + ownerEnv, + ); + return invocation ? { ...invocation, env: ownerEnv } : null; +} + +function shimNames(platform) { + return platform === "win32" + ? ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"] + : ["ocx", "opencodex"]; +} + +function targetVariants(packageDir, globalBinDir, platform) { + const packageCandidates = [packageDir]; + try { packageCandidates.push(realpathSync(packageDir)); } catch { /* keep lexical path */ } + const binCandidates = [globalBinDir]; + try { binCandidates.push(realpathSync(globalBinDir)); } catch { /* keep lexical path */ } + const launcherCandidates = packageCandidates.map(candidate => join(candidate, "bin", "ocx.mjs")); + const relativeCandidates = binCandidates.flatMap(bin => launcherCandidates.map(candidate => relative(bin, candidate))); + return [...new Set([...launcherCandidates, ...relativeCandidates].map(value => { + const normalised = String(value).replaceAll("\\", "/").replace(/^\.\//, ""); + return platform === "win32" ? normalised.toLowerCase() : normalised; + }))].filter(Boolean); +} + +function shimPointsToPackage(shimPath, packageDir, globalBinDir, platform) { + try { + if (samePath(shimPath, join(packageDir, "bin", "ocx.mjs"), platform)) return true; + } catch { /* fall through to text inspection */ } + let text; + try { + text = readFileSync(shimPath, "utf8").replaceAll("\\", "/"); + if (platform === "win32") text = text.toLowerCase(); + } catch { + return false; + } + // A comment containing the new path is not a launcher. Generated cmd/PowerShell + // shims are simple enough that the target appears on an executable line; discard + // shebang/hash and REM lines before matching so a stale or hand-edited shim cannot + // pass verification by mentioning the right package in a comment. + const executableText = text.split(/\r?\n/).filter(line => { + const trimmed = line.trim(); + return !trimmed.startsWith("#") && !/^rem(?:\s|$)/i.test(trimmed); + }).join("\n"); + const expectedLauncher = join(packageDir, "bin", "ocx.mjs"); + if (shimTargetPaths(shimPath, executableText, platform).some(target => samePath(target, expectedLauncher, platform))) { + return true; + } + return targetVariants(packageDir, globalBinDir, platform).some(target => executableText.includes(target)); +} + +function shimIsRunnable(shimPath, platform) { + if (platform === "win32") return true; + try { + return (statSync(shimPath).mode & 0o111) !== 0; + } catch { + return false; + } +} + +function shimTargetPaths(shimPath, text, platform) { + const targets = []; + const launcherPattern = /((?:\$basedir(?:_win)?|\$PSScriptRoot|%~dp0|[A-Za-z]:[\\/]|\/|\.\.?[\\/])[^"'`\r\n]*[\\/]bin[\\/]ocx\.mjs)/gi; + const shimDir = pathDirname(shimPath, platform); + for (const match of text.matchAll(launcherPattern)) { + let target = match[1]; + target = target.replace(/\$basedir_win|\$basedir|\$PSScriptRoot|%~dp0/gi, shimDir); + target = target.replaceAll("\\", "/"); + targets.push(isAbsolutePath(target, platform) + ? target + : platform === "win32" ? win32.resolve(shimDir, target) : resolve(shimDir, target)); + } + return targets; +} + +/** + * Verify both generated command names and all launcher forms pnpm supports for + * the current platform. A package list/tree check is not enough: a stale shim + * can still execute the old global group after pnpm changes the active link. + */ +export function verifyPnpmGlobalShims( + packageDir, + globalBinDir, + platform = process.platform, + exists = existsSync, +) { + if (!isAbsolutePath(globalBinDir, platform)) { + return { ok: false, reason: "pnpm global bin directory is not absolute" }; + } + const missing = []; + for (const name of shimNames(platform)) { + const path = join(globalBinDir, name); + if ( + !exists(path) + || !shimIsRunnable(path, platform) + || !shimPointsToPackage(path, packageDir, globalBinDir, platform) + ) missing.push(name); + } + return missing.length === 0 + ? { ok: true } + : { ok: false, reason: `pnpm generated shim verification failed (${missing.join(", ")})` }; +} + +/** + * Read and verify the package manager's active global link. `owner` constraints + * make the listing a proof of the group selected during preflight, not merely a + * successful listing from whichever pnpm happens to be first on PATH. + */ +export function readPnpmGlobalPackage( + packageName, + runPnpm, + verify = verifyPnpmInstallTree, + constraints = {}, +) { + const platform = constraints.platform ?? process.platform; + const expectedGlobalDir = constraints.expectedGlobalDir ?? constraints.owner?.globalDir; + const expectedGlobalRoot = constraints.expectedGlobalRoot ?? constraints.owner?.globalRoot; + const globalBinDir = constraints.globalBinDir ?? constraints.owner?.globalBinDir; + let result; + try { + result = runPnpm(ownerGlobalArgs(["list", "-g", "--depth=0", "--json", packageName], constraints.owner), true); + } catch { + return { ok: false, reason: "pnpm global package listing failed" }; + } + if (result?.status !== 0) return { ok: false, reason: "pnpm global package listing failed" }; + + const inspected = inspectListOutput(result.stdout, packageName); + const entry = inspected?.entry; + const version = typeof entry?.version === "string" ? entry.version.trim() : ""; + const packagePath = typeof entry?.path === "string" ? entry.path : ""; + if (!version || !packagePath || !isAbsolutePath(packagePath, platform)) { + return { ok: false, reason: "pnpm did not report a valid active global package" }; + } + if (constraints.expectedPackagePath && !samePath(packagePath, constraints.expectedPackagePath, platform)) { + return { ok: false, reason: "pnpm active package is not the running package" }; + } + + const rootPath = normaliseGlobalRoot(inspected?.root?.path, platform); + if (expectedGlobalRoot || expectedGlobalDir) { + // pnpm versions have reported either the global group or its node_modules root; + // accept both, but require the root when the caller is proving ownership. Without + // it a successful package listing cannot distinguish a command that ignored the + // pinned group from the selected group. + if (!rootPath) return { ok: false, reason: "pnpm did not report the selected global group" }; + const rootMatches = expectedGlobalRoot + ? samePath(rootPath, expectedGlobalRoot, platform) + : globalRootMatchesDir(rootPath, expectedGlobalDir, platform); + if (!rootMatches) return { ok: false, reason: "pnpm listed a different global group" }; + } + + let tree; + try { + tree = verify(packagePath, version); + } catch { + return { ok: false, reason: "the active pnpm package could not be verified" }; + } + if (!tree?.ok) return { ok: false, reason: "the active pnpm package failed verification" }; + + // A valid package tree/group is enough to bind the owner before an update. The + // existing shim may be stale from an older pnpm run; post-update and rollback + // reads leave this enabled so a successful transaction must produce fresh shims. + if (globalBinDir && constraints.checkShims !== false) { + const shims = (constraints.verifyShims ?? verifyPnpmGlobalShims)( + packagePath, + globalBinDir, + platform, + ); + if (!shims?.ok) return { ok: false, reason: shims?.reason ?? "pnpm global shims failed verification" }; + } + return { + ok: true, + version, + path: packagePath, + globalDir: expectedGlobalDir, + globalRoot: rootPath || expectedGlobalRoot, + globalBinDir, + }; +} + +function statusText(status) { + return status === null || status === undefined ? "?" : String(status); +} + +function packageSpec(packageName, versionOrTag) { + return `${packageName}@${versionOrTag}`; +} + +function listGlobalPackage(commandPath, packageName, runPnpm) { + try { + return runPnpm(commandPath, ["list", "-g", "--depth=0", "--json", packageName], true); + } catch { + return null; + } +} + +/** + * Find the pnpm executable and global group that own the running package. Every + * candidate is inspected independently; this matters when two pnpm homes expose + * the same pnpm version but only one owns the current package/shim. + */ +export function resolvePnpmGlobalOwner({ + packageName, + packagePath, + commandPaths, + runningShimPath, + runPnpm, + verify = verifyPnpmInstallTree, + platform = process.platform, +}) { + if (!isAbsolutePath(packagePath, platform)) return { ok: false, reason: "running pnpm package path is not absolute" }; + let lastReason = "no pnpm global installation owns the running package"; + + for (const commandPath of commandPaths ?? []) { + const list = listGlobalPackage(commandPath, packageName, runPnpm); + if (list?.status !== 0) continue; + const inspected = inspectListOutput(list.stdout, packageName); + const listedPath = typeof inspected?.entry?.path === "string" ? inspected.entry.path : ""; + if (!listedPath || !isAbsolutePath(listedPath, platform) || !samePath(listedPath, packagePath, platform)) continue; + + const paths = resolveGlobalPaths(commandPath, runPnpm, inspected?.root?.path, platform); + if (!paths) { + lastReason = "pnpm owns the running package but did not report its global group and bin directory"; + continue; + } + const { globalDir, globalRoot, globalBinDir } = paths; + + // If the process was entered through a generated command shim, its directory is + // another owner fact. This disambiguates two pnpm homes that expose the same pnpm + // version and (for example after a copied prefix) report the same package path. + // Direct `node bin/ocx.mjs` and Windows shims that invoke the package path do not + // provide a usable shim path, so they continue to rely on the package/group proof. + if (runningShimPath) { + const shimName = String(runningShimPath).replaceAll("\\", "/").split("/").at(-1)?.toLowerCase(); + const isCommandShim = ["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"].includes(shimName ?? ""); + if (isCommandShim && !samePath(dirname(runningShimPath), globalBinDir, platform)) { + lastReason = "pnpm package owner did not match the running global shim"; + continue; + } + } + + const owner = { commandPath, packagePath: listedPath, globalDir, globalRoot, globalBinDir }; + const active = readPnpmGlobalPackage( + packageName, + (args, capture = false) => runPnpm(commandPath, args, capture), + verify, + { + owner, + expectedPackagePath: packagePath, + expectedGlobalDir: globalDir, + expectedGlobalRoot: globalRoot, + globalBinDir, + // Owner binding must remain possible when an older pnpm invocation left + // the top-level shim stale; the transaction verifies it after mutation. + checkShims: false, + platform, + }, + ); + if (active.ok) return { ok: true, owner: { ...owner, packagePath: active.path, version: active.version } }; + lastReason = active.reason; + } + return { ok: false, reason: lastReason }; +} + +/** + * Update a pnpm global package through pnpm itself. Never rename or remove files in + * the pnpm store. Every command is pinned to the owner discovered before the proxy + * is stopped. The pre-update proof accepts an existing stale shim; success and + * rollback require a valid tree plus fresh generated shims. + */ +export function runPnpmGlobalUpdate({ + packageName, + currentVersion, + targetVersion, + tag, + owner, + runningPackagePath, + runPnpm, + verify = verifyPnpmInstallTree, + verifyShims = verifyPnpmGlobalShims, + platform = process.platform, + log = () => {}, +}) { + if ( + !owner?.commandPath + || !owner?.globalDir + || !owner?.globalRoot + || !owner?.globalBinDir + || !isAbsolutePath(owner.commandPath, platform) + || !isAbsolutePath(owner.globalDir, platform) + || !isAbsolutePath(owner.globalBinDir, platform) + ) { + return { ok: false, phase: "preflight", error: "pnpm global owner was not pinned" }; + } + const constraints = { + owner, + expectedPackagePath: runningPackagePath ?? owner.packagePath, + expectedGlobalDir: owner.globalDir, + expectedGlobalRoot: owner.globalRoot, + globalBinDir: owner.globalBinDir, + verifyShims, + platform, + }; + const before = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, checkShims: false }, + ); + if (!before.ok) return { ok: false, phase: "preflight", error: before.reason }; + if (currentVersion && before.version !== currentVersion) { + return { ok: false, phase: "preflight", error: "pnpm's active package does not match the running package" }; + } + + const requested = targetVersion || tag; + const spec = packageSpec(packageName, requested); + log(`Updating ${spec} with pnpm…`); + let install; + try { + install = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, spec], owner), false); + } catch { + install = { status: 1 }; + } + + // The package path may switch to a different group link, so only constrain + // the group/bin pair after the command; the active package path is intentionally open. + const after = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, expectedPackagePath: undefined }, + ); + const targetMatches = after.ok && (!targetVersion || after.version === targetVersion); + if (install?.status === 0 && targetMatches) { + return { + ok: true, + phase: "done", + version: after.version, + path: after.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + }; + } + + const installReason = install?.status !== 0 + ? `pnpm update failed (${statusText(install?.status)})` + : after.ok + ? "pnpm update produced an unexpected active package version" + : "pnpm update completed but the active package failed verification"; + + // A failed command may have left the original active group intact. Do not create a + // second update transaction in that case; the verified previous package is already safe. + if (after.ok && after.version === before.version) { + return { + ok: false, + phase: "install", + rolledBack: true, + activePath: after.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version remains active`, + }; + } + + log(`Restoring ${packageName}@${before.version} with pnpm…`); + let rollback; + try { + rollback = runPnpm(ownerGlobalArgs(["add", "-g", PNPM_BUILD_APPROVAL, packageSpec(packageName, before.version)], owner), false); + } catch { + rollback = { status: 1 }; + } + const restored = readPnpmGlobalPackage( + packageName, + runPnpm, + verify, + { ...constraints, expectedPackagePath: undefined }, + ); + if (rollback?.status === 0 && restored.ok && restored.version === before.version) { + return { + ok: false, + phase: "rollback", + rolledBack: true, + activePath: restored.path, + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version restored`, + }; + } + return { + ok: false, + phase: "rollback", + rolledBack: false, + ...(restored.ok ? { activePath: restored.path } : {}), + globalDir: owner.globalDir, + globalBinDir: owner.globalBinDir, + error: `${installReason}; previous version could not be verified after rollback`, + }; +} diff --git a/src/update/pnpm-invocation.d.mts b/src/update/pnpm-invocation.d.mts new file mode 100644 index 0000000000..4ae7a865d3 --- /dev/null +++ b/src/update/pnpm-invocation.d.mts @@ -0,0 +1,43 @@ +export interface PnpmInvocationDeps { + cwd?: string; + exists?: (path: string) => boolean; +} + +export interface PnpmInvocation { + file: string; + args: string[]; + options: { windowsVerbatimArguments?: boolean }; +} + +export declare function resolvePnpmCommands( + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): string[]; + +export declare function pnpmInvocationForPath( + pnpm: string, + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, +): PnpmInvocation | null; + +export declare function resolvePnpmCommand( + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): string | null; + +export declare function pnpmInvocation( + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): PnpmInvocation | null; + +export declare function pnpmInvocations( + args: readonly string[], + platform?: NodeJS.Platform, + env?: Record, + deps?: PnpmInvocationDeps, +): PnpmInvocation[]; diff --git a/src/update/pnpm-invocation.mjs b/src/update/pnpm-invocation.mjs new file mode 100644 index 0000000000..aa5de3b699 --- /dev/null +++ b/src/update/pnpm-invocation.mjs @@ -0,0 +1,141 @@ +import { existsSync } from "node:fs"; +import { win32 } from "node:path"; + +const CMD_META = /([()%!^"`<>&|;, *?])/g; + +function escapeCmdArg(arg) { + const out = String(arg).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\*)$/, "$1$1"); + return `"${out}"`.replace(CMD_META, "^$1"); +} + +function escapeCmdCommand(command) { + return command.replace(CMD_META, "^$1"); +} + +function cleanPathEntry(entry) { + const trimmed = entry.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1); + return trimmed; +} + +function pathEntries(platform, env) { + const raw = env.PATH ?? env.Path ?? ""; + const delimiter = platform === "win32" ? win32.delimiter : ":"; + return raw.split(delimiter).map(cleanPathEntry).filter(Boolean); +} + +function isCurrentDirectory(cwd, entry) { + const left = win32.resolve(entry); + const right = win32.resolve(cwd); + return left.toLowerCase() === right.toLowerCase(); +} + +function systemCommandProcessor(env) { + const systemRoot = env.SystemRoot ?? env.windir; + if (systemRoot && win32.isAbsolute(systemRoot)) { + return win32.join(systemRoot, "System32", "cmd.exe"); + } + const comSpec = env.ComSpec; + return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null; +} + +function commandPaths(platform, env, deps) { + const exists = deps.exists ?? existsSync; + const cwd = deps.cwd ?? process.cwd(); + const entries = pathEntries(platform, env); + const paths = []; + + if (platform !== "win32") { + for (const entry of entries) { + if (!entry.startsWith("/")) continue; + const candidate = `${entry}/pnpm`; + if (exists(candidate) && !paths.includes(candidate)) paths.push(candidate); + } + return paths; + } + + const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean); + for (const entry of entries) { + if (!win32.isAbsolute(entry) || isCurrentDirectory(cwd, entry)) continue; + for (const extension of extensions) { + const candidate = win32.join(entry, `pnpm${extension.toLowerCase()}`); + if (exists(candidate)) { + const resolved = win32.resolve(candidate); + if (!paths.some(path => path.toLowerCase() === resolved.toLowerCase())) paths.push(resolved); + } + } + } + return paths; +} + +function invocationForPath(pnpm, args, platform, env) { + if (platform !== "win32" || !/\.(cmd|bat)$/i.test(pnpm)) { + return { file: pnpm, args: [...args], options: {} }; + } + + const commandProcessor = systemCommandProcessor(env); + if (!commandProcessor) return null; + const line = [escapeCmdCommand(pnpm), ...args.map(escapeCmdArg)].join(" "); + return { + file: commandProcessor, + args: ["/d", "/s", "/c", `"${line}"`], + options: { windowsVerbatimArguments: true }, + }; +} + +/** Return every absolute pnpm executable candidate in PATH, in shell order. */ +export function resolvePnpmCommands( + platform = process.platform, + env = process.env, + deps = {}, +) { + return commandPaths(platform, env, deps); +} + +/** Build an invocation for one already-selected pnpm executable. */ +export function pnpmInvocationForPath( + pnpm, + args, + platform = process.platform, + env = process.env, +) { + return invocationForPath(pnpm, args, platform, env); +} + +/** + * Resolve pnpm without relying on cmd.exe's implicit current-directory lookup on Windows. + * POSIX returns an absolute PATH candidate as well, so a service receives the same + * executable that the interactive shell selected. + */ +export function resolvePnpmCommand( + platform = process.platform, + env = process.env, + deps = {}, +) { + return resolvePnpmCommands(platform, env, deps)[0] ?? null; +} + +export function pnpmInvocation( + args, + platform = process.platform, + env = process.env, + deps = {}, +) { + const pnpm = resolvePnpmCommand(platform, env, deps); + if (!pnpm) return null; + return invocationForPath(pnpm, args, platform, env); +} + +/** Return invocations for every absolute pnpm candidate in PATH. */ +export function pnpmInvocations( + args, + platform = process.platform, + env = process.env, + deps = {}, +) { + return resolvePnpmCommands(platform, env, deps) + .map(command => invocationForPath(command, args, platform, env)) + .filter(Boolean); +} diff --git a/src/update/registry-integrity.d.mts b/src/update/registry-integrity.d.mts new file mode 100644 index 0000000000..e862672162 --- /dev/null +++ b/src/update/registry-integrity.d.mts @@ -0,0 +1,16 @@ +export interface RegistryCommandResult { + status: number | null; + stdout?: string | Uint8Array | null; + stderr?: string | Uint8Array | null; +} + +export type RegistryIntegrityResult = + | { ok: true; integrity: string } + | { ok: false; reason: string } + | { ok: "skipped"; reason: string }; + +export declare function checkRegistryPackageIntegrity( + packageName: string, + version: string | null | undefined, + run: (args: readonly string[], capture?: boolean) => RegistryCommandResult, +): RegistryIntegrityResult; diff --git a/src/update/registry-integrity.mjs b/src/update/registry-integrity.mjs new file mode 100644 index 0000000000..3c0f5f3893 --- /dev/null +++ b/src/update/registry-integrity.mjs @@ -0,0 +1,37 @@ +/** + * Shared registry metadata pre-flight for every direct package-manager launcher. + * + * This intentionally does not perform the query itself. The caller supplies the + * already-hardened npm/pnpm invocation, so the plain Node launcher and the Bun + * update worker apply exactly the same integrity policy without importing TypeScript + * into the published launcher. + */ +function outputText(value) { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + return ""; +} + +/** + * Check the registry's dist.integrity value for one immutable package version. + * A failed query is a best-effort skip; successful metadata without a sha512 SRI + * value is anomalous and fails closed before the caller changes local state. + */ +export function checkRegistryPackageIntegrity(packageName, version, run) { + if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" }; + + let result; + try { + result = run(["view", `${packageName}@${version}`, "dist.integrity"], true); + } catch { + return { ok: "skipped", reason: "registry integrity query failed" }; + } + if (result?.status !== 0) { + return { ok: "skipped", reason: `registry integrity query failed (status ${result?.status ?? "timeout"})` }; + } + + const tokens = outputText(result.stdout).replace(/["']/g, "").trim().split(/\s+/).filter(Boolean); + const integrity = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token)); + if (!integrity) return { ok: false, reason: `registry returned no sha512 integrity for ${packageName}@${version}` }; + return { ok: true, integrity }; +} diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts index 398639e4f9..11d1c1970e 100644 --- a/src/update/transactional-install.d.mts +++ b/src/update/transactional-install.d.mts @@ -1,5 +1,6 @@ export type InstallTreeVerification = { ok: boolean; failures: string[] }; export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification; +export function verifyPnpmInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification; export function bootRestoreProbe( packageDir: string, deps?: { rename?: (from: string, to: string) => void }, @@ -19,4 +20,3 @@ export function transactionalNpmUpdate(args: { rolledBack?: boolean; backup?: string; }; - diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index 9b61c9fe33..76c6acd29a 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -17,11 +17,88 @@ * /.ocx-recovery.json double-fault marker with a one-line restore */ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; + +/** + * Dependency lookup confined to the candidate's OWN tree. This is the npm contract and it + * must stay lexical: Node's resolver walks the ancestor directory chain, so a global npm + * candidate at /lib/node_modules/@scope/pkg could satisfy its bundled-Bun + * requirement from /lib/node_modules/bun, which belongs to a different package. + * That verdict is not cosmetic — it accepts a stage that cannot start (D2), skips the + * post-swap rollback, and lets bootRestoreProbe reap the only known-good backup. + */ +function candidateTreeDependencyDir(packageDir, name) { + const dir = join(packageDir, "node_modules", ...name.split("/")); + return existsSync(join(dir, "package.json")) ? dir : undefined; +} + +/** + * The candidate's own bun directory, whether or not it carries a readable package.json. + * The size gate keys on the DIRECTORY, matching the pre-carry verifier: a half-extracted + * node_modules/bun holding a truncated binary and no manifest is still a broken tree, and + * bun is not always among the sentinels, so the sentinel loop cannot be relied on to catch it. + */ +function ownTreeBunDir(packageDir) { + const dir = join(packageDir, "node_modules", "bun"); + return existsSync(dir) ? dir : undefined; +} + +/** The node_modules directory a package sits directly inside, or undefined. */ +function enclosingNodeModules(packageDir) { + const parent = dirname(packageDir); + if (basename(parent) === "node_modules") return parent; + // Scoped packages live one level deeper: /@scope/name. + const grandparent = dirname(parent); + if (basename(parent).startsWith("@") && basename(grandparent) === "node_modules") return grandparent; + return undefined; +} + +/** pnpm's own bookkeeping at the root of a node_modules tree it manages. */ +function isPnpmManagedRoot(nodeModulesDir) { + if (!nodeModulesDir) return false; + if (nodeModulesDir.split(/[\\/]/).includes(".pnpm")) return true; + return existsSync(join(nodeModulesDir, ".pnpm")) || existsSync(join(nodeModulesDir, ".modules.yaml")); +} + +/** + * Dependency roots this package INSTANCE owns. pnpm exposes dependencies in several shapes — + * symlinks inside the package's own node_modules, a package root that is itself a symlink into + * the virtual store, or a hoisted group root — so the npm rule alone rejects healthy trees. + * Ownership is still bounded: an enclosing node_modules counts only when pnpm's own metadata + * says pnpm manages it, which keeps an unrelated ancestor installation out. + */ +function ownedDependencyRoots(packageDir) { + const roots = []; + const add = dir => { if (dir && !roots.includes(dir)) roots.push(dir); }; + const lexicalGroup = enclosingNodeModules(packageDir); + add(join(packageDir, "node_modules")); + let real; + try { real = realpathSync(packageDir); } catch { /* keep the lexical path only */ } + if (real && real !== packageDir) add(join(real, "node_modules")); + if (isPnpmManagedRoot(lexicalGroup)) add(lexicalGroup); + const realGroup = real ? enclosingNodeModules(real) : undefined; + if (isPnpmManagedRoot(realGroup)) add(realGroup); + return roots; +} + +/** + * pnpm dependency lookup. Probing the owned roots directly, rather than filtering whatever + * Node's resolver returned, is deliberate: require.resolve reports the REALPATH of the + * resolved file, so a dependency reached through pnpm's own node_modules symlink comes back + * as a virtual-store path that no lexical ownership test can recognise. existsSync follows + * the symlink, which is exactly the pnpm graph edge that proves ownership. + */ +function pnpmOwnedDependencyDir(packageDir, name) { + for (const root of ownedDependencyRoots(packageDir)) { + const dir = join(root, ...name.split("/")); + if (existsSync(join(dir, "package.json"))) return dir; + } + return undefined; +} /** Verification manifest for a staged (or live) package tree. */ -export function verifyInstallTree(packageDir, expectedVersion) { +function verifyTreeWithDependencyLookup(packageDir, expectedVersion, dependencyDir) { const failures = []; let pkg; try { @@ -42,8 +119,8 @@ export function verifyInstallTree(packageDir, expectedVersion) { // The bundled Bun binary is the load-bearing artifact: without it the launcher exits // before serving anything, and a boot probe that called this tree healthy would reap // the only backup (review High 3). Size-gate the real binary, not just its package.json. - const bunPkgDir = join(packageDir, "node_modules", "bun"); - if (existsSync(bunPkgDir)) { + const bunPkgDir = dependencyDir(packageDir, "bun") ?? ownTreeBunDir(packageDir); + if (bunPkgDir) { const bunBinary = findLargestFile(bunPkgDir); if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) { failures.push("bundled Bun binary missing or truncated (< 10MB)"); @@ -56,12 +133,29 @@ export function verifyInstallTree(packageDir, expectedVersion) { ? deps.filter(name => name === "bun" || name === "zod") : deps.slice(0, 2); for (const name of sentinels) { - const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json"); - if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name); + if (!dependencyDir(packageDir, name)) failures.push("sentinel dependency missing: " + name); } return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures }; } +/** + * npm (and every recovery decision): the candidate must be self-contained. Used by + * transactionalNpmUpdate's stage and post-swap checks and by bootRestoreProbe. + */ +export function verifyInstallTree(packageDir, expectedVersion) { + return verifyTreeWithDependencyLookup(packageDir, expectedVersion, candidateTreeDependencyDir); +} + +/** + * Verify a package exposed through pnpm's global virtual store. pnpm 10/11 may use an + * isolated virtual store, a custom virtualStoreDir, global virtual-store links, or a + * hoisted linker, so the dependency may sit outside the package directory — but it must + * still be reachable through a root this package instance owns. + */ +export function verifyPnpmInstallTree(packageDir, expectedVersion) { + return verifyTreeWithDependencyLookup(packageDir, expectedVersion, pnpmOwnedDependencyDir); +} + function stampedName(prefix) { return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-"); } diff --git a/src/update/tray-update-plan.mjs b/src/update/tray-update-plan.mjs index 695a3467aa..606a5d254c 100644 --- a/src/update/tray-update-plan.mjs +++ b/src/update/tray-update-plan.mjs @@ -1,6 +1,6 @@ /** * Shared, side-effect-free contract for preserving the Windows tray across all - * updater entry points (npm launcher, CLI updater, and GUI worker). + * updater entry points (package launcher, CLI updater, and GUI worker). */ export function planWindowsTrayUpdate(status) { const installed = status?.installed === true; diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index f6c50559f5..fc41950115 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -181,15 +181,17 @@ exit 0 }, ); - test("Node launcher handles npm self-update before starting Bun", async () => { + test("Node launcher handles package-manager self-update before starting Bun", async () => { const launcher = await readText("bin/ocx.mjs"); expect(launcher).toContain('process.argv[2] === "update"'); expect(launcher).toContain('["install", "-g", `${PKG}@${tag}`]'); + expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]'); expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"'); expect(launcher).toContain("!isBunGlobalInstall()"); - expect(launcher).toContain("repairCodexShimIfNeeded()"); + expect(launcher).toContain("repairCodexShimIfNeeded(postUpdateLauncher)"); expect(launcher).toContain("runNpmSelfUpdate()"); + expect(launcher).toContain("runPnpmSelfUpdate()"); }); test("release helper watches the workflow run it just dispatched", async () => { diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index b6064d2284..b6edb5b412 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -311,7 +311,7 @@ function isolatedLauncherEnv(root: string, override: string): NodeJS.ProcessEnv }; } -describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => { +describe.skipIf(!nodeAvailable)("ocx package launcher relative Bun override", () => { test("resolves a valid bare relative override before spawning", () => { const root = mkdtempSync(join(tmpdir(), "ocx-launcher-relative-")); try { @@ -358,7 +358,7 @@ describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => }, 60_000); }); -describe.skipIf(!runnable)("ocx npm launcher effective Bun runtime", () => { +describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { test("uses a valid OPENCODEX_BUN_PATH for the actual proxy process", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-copy-")); try { diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 169646aeba..f15293f48a 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -14,7 +14,7 @@ const validatorSource = readFileSync( "utf8", ); -describe("ocx.mjs npm launcher (source invariants)", () => { +describe("ocx.mjs package launcher (source invariants)", () => { test("the Bun child receives the runtime provenance the launcher actually selected (#848)", () => { // The launcher is a plain-Node bin script executing at import time, so this is // asserted at the source level: the marker must reach the spawn env, and it must @@ -58,9 +58,12 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(spawnCall).toContain("windowsHide: true"); }); - test("Windows npm spawns use the trusted absolute invocation without shell lookup", () => { - expect(source).toContain("const latestInvocation = npmInvocation("); - expect(source).toContain("const installInvocation = npmInvocation("); + test("Windows package-manager spawns use the trusted absolute invocation without shell lookup", () => { + expect(source).toContain("resolvePnpmGlobalOwner"); + expect(source).toContain("const managerInvocation = args => manager === \"pnpm\""); + expect(source).toContain("pnpmOwnerInvocation(owner, args)"); + expect(source).toContain("const latestInvocation = managerInvocation("); + expect(source).toContain("const installInvocation = managerInvocation(installArgs);"); expect(source).toContain("spawnSync(latestInvocation.file, latestInvocation.args"); // #1942: the staged install spawns through the same hardened npmInvocation resolver // inside the transactional runNpm callback. diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index b9bc83299b..6336474557 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -19,7 +19,15 @@ describe("Codex CLI updater launcher policy", () => { test("launcher skips boot repair and lazy Bun installation for this namespace", () => { const source = readFileSync(repoPath("bin", "ocx.mjs"), "utf8"); - expect(source).toContain("!codexCliUpdateInspection && isNodeModulesInstall()"); + // Read the guard that actually wraps the probe rather than a fixed pair of adjacent + // clauses. The boot probe is npm's transactional layout (stage/swap/backup) and pnpm + // rolls back through its own global path, so the condition list grows; what must not + // change is that this namespace is excluded from it. + const probeCall = source.indexOf("const probe = bootRestoreProbe("); + expect(probeCall).toBeGreaterThan(0); + const guard = source.slice(source.lastIndexOf("if (", probeCall), probeCall); + expect(guard).toContain("!codexCliUpdateInspection"); + expect(guard).toContain("isNodeModulesInstall()"); expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); expect(source).toContain("if (allowInstall && existsSync(installJs))"); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..a9784f67e1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1067,10 +1067,12 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", + "update-tree-ownership.test.ts": "update", "upstream-connect-error.test.ts": "server", "upstream-http-error.test.ts": "adapters", "upstream-http-version.test.ts": "server", diff --git a/tests/update/update-badge.test.ts b/tests/update/update-badge.test.ts index b290b7615c..c961fc466f 100644 --- a/tests/update/update-badge.test.ts +++ b/tests/update/update-badge.test.ts @@ -57,7 +57,7 @@ describe("readUpdateBadge", () => { test("reading the badge never spawns a registry refresh", () => { // The GUI polls this endpoint. A refresh-on-read would let repeated polls launch - // repeated `npm view` helpers with no coalescing, so the deps surface has no + // repeated manager `view` helpers with no coalescing, so the deps surface has no // refresh hook at all — this test pins that shape. const keys = Object.keys(deps({})); expect(keys).toEqual(["currentVersion", "detectInstall", "readCache"]); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 6e02ecd575..9ba42b8150 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -436,6 +436,55 @@ describe("GUI update execution decisions", () => { expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "preview"]); }); + test("pnpm worker uses the Node launcher update path", () => { + const cmd = updateExecutionCommand("pnpm", "latest", "/pkg/bin/ocx.mjs"); + expect(cmd.bin).toMatch(/^node/); + expect(cmd.args).toEqual(["/pkg/bin/ocx.mjs", "update", "--tag", "latest"]); + }); + + test("pnpm GUI worker passes the verified active launcher into restart recovery", async () => { + const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs"; + let restartLauncher = ""; + let now = 0; + await runGuiUpdateWorker("pnpm-active-launcher", "latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "pnpm", + updateAvailable: true, + canUpdate: true, + command: "node /old/bin/ocx.mjs update --tag latest", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + resolvePnpmOwnerFn: () => ({ + ok: true as const, + owner: { + commandPath: "/pnpm/owner/bin/pnpm", + packagePath: "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex", + globalDir: "/pnpm/owner/global", + globalRoot: "/pnpm/owner/global/v11", + globalBinDir: "/pnpm/owner/bin", + }, + }), + resolvePnpmActiveLauncherFn: () => activeLauncher, + integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + runCommandFn: () => ({ status: 0, signal: null }), + restartIo: { + serviceInstalledFn: () => false, + restartAfterUpdateFn: async (_job, _captured, io) => { + restartLauncher = io?.packageLauncherPathFn?.() ?? ""; + }, + probeProxy: async () => true, + probeProxyIdentity: async () => ({ pid: 4242, version: "2.7.41" }), + now: () => now, + sleepMs: async ms => { now += ms; }, + }, + }); + expect(restartLauncher).toBe(activeLauncher); + expect(readUpdateJob("pnpm-active-launcher")?.status).toBe("succeeded"); + }); + test("restart command separates service and direct proxy modes", () => { expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs")).toMatchObject({ mode: "service", @@ -445,6 +494,55 @@ describe("GUI update execution decisions", () => { mode: "proxy", args: ["/pkg/bin/ocx.mjs", "start"], }); + expect(restartCommand(true, "pnpm", "/pkg/bin/ocx.mjs")).toMatchObject({ + mode: "service", + args: ["/pkg/bin/ocx.mjs", "service", "repair"], + }); + }); + + test("restart recovery uses the verified active launcher for direct and service paths", async () => { + const activeLauncher = "/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex/bin/ocx.mjs"; + const directJob: UpdateJobState = { + id: "restart-active-launcher-direct", + status: "restarting", + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currentVersion: "2.7.40", + latestVersion: "2.7.41", + channel: "latest", + installer: "pnpm", + restart: true, + command: "", + log: [], + }; + writeFileSync(updateJobPath(directJob.id), JSON.stringify(directJob)); + let directLauncher = ""; + await restartAfterUpdateForTests(directJob, { port: 19001, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => false, + packageLauncherPathFn: () => activeLauncher, + listListenPidsFn: () => [], + waitForPort: async () => true, + spawnStart: (_job, _installer, _port, launcher) => { directLauncher = launcher ?? ""; }, + }); + expect(directLauncher).toBe(activeLauncher); + + const serviceJob = { ...directJob, id: "restart-active-launcher-service" }; + writeFileSync(updateJobPath(serviceJob.id), JSON.stringify(serviceJob)); + let serviceArgs: string[] = []; + await restartAfterUpdateForTests(serviceJob, { port: 19002, hostname: "127.0.0.1" }, { + serviceInstalledFn: () => true, + packageLauncherPathFn: () => activeLauncher, + listListenPidsFn: () => [], + waitForPort: async () => true, + runService: (_job, _bin, args) => { + serviceArgs = args; + return { status: 0 }; + }, + serviceViableFn: () => true, + probeProxy: async () => true, + serviceHealthTimeoutMs: 1_000, + }); + expect(serviceArgs).toEqual([activeLauncher, "service", "repair"]); }); test("service restart is not skipped when the listener scan fails", async () => { @@ -1605,7 +1703,7 @@ describe("immutable update target (WP160)", () => { test("GUI worker gates integrity before spawning and fails the job on anomalous metadata", async () => { const source = await Bun.file(new URL("../../src/update/job.ts", import.meta.url)).text(); - const gateAt = source.indexOf("const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);"); + const gateAt = source.indexOf("checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner)"); const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); diff --git a/tests/update/update-pnpm.test.ts b/tests/update/update-pnpm.test.ts new file mode 100644 index 0000000000..aadf1ce960 --- /dev/null +++ b/tests/update/update-pnpm.test.ts @@ -0,0 +1,620 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + detectInstallFromPath, +} from "../../src/update/install-detection.mjs"; +import { + pnpmInvocation, + pnpmInvocations, + resolvePnpmCommand, + resolvePnpmCommands, +} from "../../src/update/pnpm-invocation.mjs"; +import { + pnpmGlobalCommandArgs, + pnpmOwnerEnvironment, + readPnpmGlobalPackage, + resolvePnpmGlobalOwner, + runPnpmGlobalUpdate, + verifyPnpmGlobalShims, + type PnpmGlobalOwner, + type PnpmRunResult, +} from "../../src/update/pnpm-global-install.mjs"; +import { checkRegistryPackageIntegrity } from "../../src/update/registry-integrity.mjs"; +import { verifyPnpmInstallTree } from "../../src/update/transactional-install.mjs"; +import { updateCommand, updateCommandStr } from "../../src/update/index"; + +const PKG = "@bitkyc08/opencodex"; + +describe("pnpm installation detection", () => { + test("requires strong evidence for legacy global/vN paths", () => { + expect(detectInstallFromPath("/work/opencodex/src/update")).toBe("source"); + expect(detectInstallFromPath("/usr/lib/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/tmp/test-user/.bun/install/global/node_modules/@bitkyc08/opencodex/bin")).toBe("bun"); + expect(detectInstallFromPath("/tmp/test-user/.bun/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/opt/global/v11/node_modules/@bitkyc08/opencodex/bin")).toBe("npm"); + expect(detectInstallFromPath("/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin", { + exists: path => path === "/opt/pnpm/global/v11/node_modules/.pnpm", + })).toBe("pnpm"); + }); + + test("recognises isolated, store-link, and preserved-symlink layouts", () => { + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/v11/node_modules/.pnpm/@bitkyc08+opencodex@2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm"); + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/store/v11/links/@bitkyc08/opencodex/2.49.0/node_modules/@bitkyc08/opencodex/bin")).toBe("pnpm"); + expect(detectInstallFromPath("/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/@bitkyc08/opencodex/bin", { + exists: path => path === "/tmp/test-user/.local/share/pnpm/global/11/group/node_modules/.pnpm", + })).toBe("pnpm"); + expect(detectInstallFromPath("C:\\work\\node_modules\\.pnpm\\@bitkyc08+opencodex@2.49.0\\node_modules\\@bitkyc08\\opencodex\\bin")).toBe("pnpm"); + }); + + test("follows a preserved npm-looking symlink to the pnpm package target", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-detect-link-")); + try { + const target = join(root, "pnpm", "global", "v11", "node_modules", ".pnpm", "pkg", "node_modules", PKG, "bin"); + const exposed = join(root, "prefix", "node_modules", PKG, "bin"); + mkdirSync(target, { recursive: true }); + mkdirSync(dirname(exposed), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(detectInstallFromPath(exposed)).toBe("pnpm"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("recognises pnpm metadata for a hoisted group without a virtual-store directory", () => { + const path = "/opt/pnpm/global/v11/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallFromPath(path, { + exists: candidate => candidate === "/opt/pnpm/global/v11/node_modules/.modules.yaml", + })).toBe("pnpm"); + }); +}); + +describe("pnpm executable selection", () => { + test("retains every absolute candidate so ownership can be matched", () => { + const env = { PATH: "/first:/second:/third" }; + const existing = new Set(["/first/pnpm", "/second/pnpm"]); + expect(resolvePnpmCommands("linux", env, { exists: path => existing.has(path) })).toEqual([ + "/first/pnpm", "/second/pnpm", + ]); + expect(pnpmInvocations(["--version"], "linux", env, { exists: path => existing.has(path) })).toHaveLength(2); + }); + + test("uses the trusted Windows pnpm shim through cmd.exe", () => { + const cwd = "C:\\work\\untrusted-project"; + const trustedPnpm = "C:\\Program Files\\pnpm\\pnpm.cmd"; + const systemCmd = "C:\\Windows\\System32\\cmd.exe"; + const env = { + PATH: `${cwd};C:\\Program Files\\pnpm`, + PATHEXT: ".CMD", + SystemRoot: "C:\\Windows", + }; + const existing = new Set([`${cwd}\\pnpm.cmd`, trustedPnpm]); + + expect(resolvePnpmCommand("win32", env, { + cwd, + exists: path => existing.has(path), + })).toBe(trustedPnpm); + + const invocation = pnpmInvocation(["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`], "win32", env, { + cwd, + exists: path => existing.has(path), + }); + expect(invocation).toMatchObject({ + file: systemCmd, + args: ["/d", "/s", "/c", expect.stringContaining("pnpm\\pnpm.cmd")], + options: { windowsVerbatimArguments: true }, + }); + expect(String(invocation?.args.at(-1) ?? "").includes(cwd)).toBe(false); + }); +}); + +const ownerFor = (version = "1.0.0"): PnpmGlobalOwner => ({ + commandPath: "/pnpm/owner/bin/pnpm", + packagePath: `/pnpm/owner/global/v11/node_modules/@bitkyc08/opencodex-${version}`, + globalDir: "/pnpm/owner/global", + globalRoot: "/pnpm/owner/global/v11", + globalBinDir: "/pnpm/owner/bin", +}); + +describe("pnpm global owner binding", () => { + test("selects the candidate whose global listing owns the running package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-owner-")); + const packagePath = join(root, "home-b", "global", "v11", "node_modules", "@bitkyc08", "opencodex"); + const groupB = join(root, "home-b", "global", "v11"); + const baseB = join(root, "home-b", "global"); + const binB = join(root, "home-b", "bin"); + mkdirSync(packagePath, { recursive: true }); + try { + const calls: { command: string; args: string[] }[] = []; + const run = (command: string, args: readonly string[], capture = false): PnpmRunResult => { + calls.push({ command, args: [...args] }); + if (args[0] === "list") { + const listed = command === "/pnpm/a/bin/pnpm" + ? join(root, "home-a", "global", "v11", "node_modules", "@bitkyc08", "opencodex") + : packagePath; + const group = command === "/pnpm/a/bin/pnpm" ? join(root, "home-a", "global", "v11") : groupB; + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: listed } } }]), + }; + } + if (args[0] === "root") { + return { status: 0, stdout: `${groupB}\n` }; + } + if (args[0] === "config" && args[2] === "global-dir") { + return { status: 0, stdout: `${baseB}\n` }; + } + if (args[0] === "config" && args[2] === "global-bin-dir") { + return { status: 0, stdout: `${binB}\n` }; + } + return { status: 1 }; + }; + + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/a/bin/pnpm", "/pnpm/b/bin/pnpm"], + runPnpm: run, + verify: () => ({ ok: true }), + }); + + expect(result).toEqual({ + ok: true, + owner: { + commandPath: "/pnpm/b/bin/pnpm", + packagePath, + version: "1.0.0", + globalDir: baseB, + globalRoot: groupB, + globalBinDir: binB, + }, + }); + expect(calls.some(call => call.command === "/pnpm/a/bin/pnpm")).toBe(true); + expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--global-dir=${baseB}`); + expect(calls.filter(call => call.command === "/pnpm/b/bin/pnpm" && call.args[0] === "list").at(-1)?.args).toContain(`--config.global-bin-dir=${binB}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the running shim to disambiguate same-version pnpm homes", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-owner-")); + const packagePath = join(root, "shared", "node_modules", PKG); + const groupA = join(root, "home-a", "global", "v11"); + const groupB = join(root, "home-b", "global", "v11"); + const baseA = join(root, "home-a", "global"); + const baseB = join(root, "home-b", "global"); + const binA = join(root, "home-a", "bin"); + const binB = join(root, "home-b", "bin"); + const runningShim = join(binB, "ocx"); + mkdirSync(packagePath, { recursive: true }); + try { + const run = (command: string, args: readonly string[]): PnpmRunResult => { + if (args[0] === "list") { + const group = command === "/pnpm/a" ? groupA : groupB; + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]), + }; + } + if (args[0] === "root") { + return { status: 0, stdout: `${command === "/pnpm/a" ? groupA : groupB}\n` }; + } + if (args[0] === "config" && args[2] === "global-dir") { + return { status: 0, stdout: `${command === "/pnpm/a" ? baseA : baseB}\n` }; + } + if (args[0] === "config" && args[2] === "global-bin-dir") { + return { status: 0, stdout: `${command === "/pnpm/a" ? binA : binB}\n` }; + } + return { status: 1 }; + }; + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/a", "/pnpm/b"], + runningShimPath: runningShim, + runPnpm: run, + verify: () => ({ ok: true }), + }); + expect(result).toMatchObject({ ok: true, owner: { commandPath: "/pnpm/b", globalDir: baseB, globalRoot: groupB, globalBinDir: binB } }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("derives pnpm's default global-dir base when config get is undefined", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-default-owner-")); + const base = join(root, "global"); + const group = join(base, "v11"); + const bin = join(root, "bin"); + const packagePath = join(group, "node_modules", PKG); + mkdirSync(packagePath, { recursive: true }); + try { + const calls: string[][] = []; + const run = (_command: string, args: readonly string[]): PnpmRunResult => { + calls.push([...args]); + if (args[0] === "list") { + return { + status: 0, + stdout: JSON.stringify([{ path: group, dependencies: { [PKG]: { version: "1.0.0", path: packagePath } } }]), + }; + } + if (args[0] === "root") return { status: 0, stdout: `${group}\n` }; + if (args[0] === "bin") return { status: 0, stdout: `${bin}\n` }; + if (args[0] === "config" && args[2] !== undefined) return { status: 0, stdout: "undefined\n" }; + return { status: 1 }; + }; + const result = resolvePnpmGlobalOwner({ + packageName: PKG, + packagePath, + commandPaths: ["/pnpm/default"], + runPnpm: run, + verify: () => ({ ok: true }), + }); + + expect(result).toEqual({ + ok: true, + owner: { commandPath: "/pnpm/default", packagePath, version: "1.0.0", globalDir: base, globalRoot: group, globalBinDir: bin }, + }); + const pinnedList = calls.find(args => args[0] === "list" && args.includes(`--global-dir=${base}`)); + expect(pinnedList).toBeDefined(); + expect(pinnedList).not.toContain(`--global-dir=${group}`); + expect(pinnedList).not.toContain(`${group}/v11`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("pins both global group and bin directory and preserves the selected PATH", () => { + const owner = ownerFor(); + expect(pnpmGlobalCommandArgs(["add", "-g", "--allow-build=bun", `${PKG}@2.0.0`], owner)).toEqual([ + "add", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", + "--allow-build=bun", + `${PKG}@2.0.0`, + ]); + const env = pnpmOwnerEnvironment(owner, { PATH: "/usr/bin" }, "linux"); + expect(env.PATH).toBe(`${owner.globalBinDir}:/usr/bin`); + expect(pnpmGlobalCommandArgs([ + "list", "-g", `--global-dir=${owner.globalDir}/wrong`, "--config.global-bin-dir", "/wrong/bin", PKG, + ], owner)).toEqual([ + "list", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", PKG, + ]); + }); + + test("rejects a pinned verification when pnpm omits the group root", () => { + const owner = ownerFor(); + const result = readPnpmGlobalPackage( + PKG, + () => ({ + status: 0, + stdout: JSON.stringify([{ dependencies: { [PKG]: { version: "1.0.0", path: owner.packagePath } } }]), + }), + () => ({ ok: true }), + { owner, expectedGlobalDir: owner.globalDir, globalBinDir: owner.globalBinDir, verifyShims: () => ({ ok: true }) }, + ); + expect(result).toEqual({ ok: false, reason: "pnpm did not report the selected global group" }); + }); +}); + +function makePackageFixture( + root: string, + dependencyRoot: string, + options: { packageDir?: string; linkDependenciesInside?: boolean } = {}, +) { + const packageDir = options.packageDir ?? join(root, "package"); + const bunDir = join(dependencyRoot, "bun"); + const zodDir = join(dependencyRoot, "zod"); + mkdirSync(join(packageDir, "bin", "nested"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules"), { recursive: true }); + mkdirSync(join(bunDir, "bin"), { recursive: true }); + mkdirSync(zodDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, + version: "2.0.0", + dependencies: { bun: "1", zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); + writeFileSync(join(bunDir, "package.json"), JSON.stringify({ name: "bun" })); + writeFileSync(join(bunDir, "bin", "bun.exe"), Buffer.alloc(10 * 1024 * 1024 + 1)); + writeFileSync(join(zodDir, "package.json"), JSON.stringify({ name: "zod" })); + if (options.linkDependenciesInside !== false) { + symlinkSync(bunDir, join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(zodDir, join(packageDir, "node_modules", "zod"), "dir"); + } + return packageDir; +} + +describe("pnpm package tree verification", () => { + test("resolves dependencies through a custom virtual store and hoisted-style links", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-")); + try { + const packageDir = makePackageFixture(root, join(root, "custom-virtual-store", "node_modules")); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves a genuinely hoisted package from an ancestor node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-hoisted-")); + try { + const hoistedRoot = join(root, "global", "node_modules"); + const packageDir = makePackageFixture(root, hoistedRoot, { + packageDir: join(hoistedRoot, "@bitkyc08", "opencodex"), + linkDependenciesInside: false, + }); + // A hoisted group is owned by pnpm only when pnpm's own bookkeeping says so; a bare + // ancestor node_modules is somebody else's installation (#4203 review, Ingwannu). + writeFileSync(join(hoistedRoot, ".modules.yaml"), "nodeLinker: hoisted\n"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves dependencies through a pnpm package-root symlink", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-linked-tree-")); + try { + const target = makePackageFixture(root, join(root, "store", "node_modules")); + const exposed = join(root, "global", "v11", "node_modules", PKG); + mkdirSync(dirname(exposed), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not accept a package tree whose runtime dependency cannot resolve", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-tree-missing-")); + try { + const packageDir = makePackageFixture(root, join(root, "deps")); + rmSync(join(packageDir, "node_modules", "zod"), { force: true }); + expect(verifyPnpmInstallTree(packageDir, "2.0.0").ok).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("pnpm generated shims", () => { + test("verifies POSIX shims point at the active package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shims-")); + try { + const packageDir = join(root, "global", "v11", "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")); + for (const name of ["ocx", "opencodex"]) { + writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); + chmodSync(join(globalBinDir, name), 0o755); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); + writeFileSync(join(globalBinDir, "opencodex"), "#!/bin/sh\nexec node ../global/v11/node_modules/old/bin/ocx.mjs\n"); + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux").ok).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts a pnpm group alias when it resolves to the active package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-shim-alias-")); + try { + const activeGroup = join(root, "global", "v11", "active"); + const aliasGroup = join(root, "global", "v11", "stable-link"); + const packageDir = join(activeGroup, "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + symlinkSync(activeGroup, aliasGroup, "dir"); + const target = relative(globalBinDir, join(aliasGroup, "node_modules", PKG, "bin", "ocx.mjs")); + for (const name of ["ocx", "opencodex"]) { + writeFileSync(join(globalBinDir, name), `#!/bin/sh\nexec node ${target} "$@"\n`); + chmodSync(join(globalBinDir, name), 0o755); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "linux")).toEqual({ ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("verifies Windows cmd and PowerShell shim forms", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-pnpm-win-shims-")); + try { + const packageDir = join(root, "global", "v11", "node_modules", PKG); + const globalBinDir = join(root, "bin"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(globalBinDir, { recursive: true }); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n"); + const target = relative(globalBinDir, join(packageDir, "bin", "ocx.mjs")).replaceAll("/", "\\"); + for (const name of ["ocx.cmd", "ocx.ps1", "opencodex.cmd", "opencodex.ps1"]) { + const body = name.endsWith(".cmd") + ? `@echo off\r\nnode "%~dp0\\${target}" %*\r\n` + : `$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\n& "$basedir\\${target}" @args\n`; + writeFileSync(join(globalBinDir, name), body); + } + expect(verifyPnpmGlobalShims(packageDir, globalBinDir, "win32")).toEqual({ ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +interface FakePnpmState { + activeVersion: string; + invalidVersions?: Set; + failVersions?: Set; + invalidShimVersions?: Set; + calls: { args: string[]; capture: boolean }[]; +} + +function fakePnpm(state: FakePnpmState, owner = ownerFor()): (args: readonly string[], capture?: boolean) => PnpmRunResult { + return (args, capture = false) => { + const normalized = [...args]; + state.calls.push({ args: normalized, capture }); + if (normalized[0] === "list") { + return { + status: 0, + stdout: JSON.stringify([{ + path: owner.globalRoot, + dependencies: { + [PKG]: { + version: state.activeVersion, + path: state.activeVersion === "1.0.0" + ? owner.packagePath + : `/virtual/pnpm/${state.activeVersion}`, + }, + }, + }]), + }; + } + if (normalized[0] !== "add") return { status: 1 }; + const spec = normalized.at(-1) ?? ""; + const requested = spec.slice(spec.lastIndexOf("@") + 1); + if (state.failVersions?.has(requested)) return { status: 1 }; + state.activeVersion = requested; + return { status: 0 }; + }; +} + +function fakeVerify(state: FakePnpmState, owner = ownerFor()) { + return (path: string, expectedVersion?: string) => ({ + ok: (path === owner.packagePath || path === `/virtual/pnpm/${state.activeVersion}`) + && expectedVersion === state.activeVersion + && !state.invalidVersions?.has(state.activeVersion), + }); +} + +function fakeShims(state: FakePnpmState) { + return (_path: string, _bin: string, _platform?: string) => ({ + ok: !state.invalidShimVersions?.has(state.activeVersion), + }); +} + +describe("pnpm global update", () => { + test("updates through the selected pnpm owner and verifies the new active group", () => { + const owner = ownerFor(); + const state: FakePnpmState = { activeVersion: "1.0.0", calls: [] }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: "1.0.0", + targetVersion: "2.0.0", + tag: "latest", + owner, + runPnpm: fakePnpm(state, owner), + verify: fakeVerify(state, owner), + verifyShims: fakeShims(state), + }); + + expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0", path: "/virtual/pnpm/2.0.0" }); + expect(state.calls.map(call => call.args)).toContainEqual([ + "add", + `--global-dir=${owner.globalDir}`, + `--config.global-bin-dir=${owner.globalBinDir}`, + "-g", "--allow-build=bun", `${PKG}@2.0.0`, + ]); + expect(state.calls.every(call => call.args[0] !== "list" || call.args.includes(`--global-dir=${owner.globalDir}`))).toBe(true); + }); + + test("does not block on a stale pre-existing shim, but requires fresh shims after update", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidShimVersions: new Set(["1.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: true, phase: "done", version: "2.0.0" }); + }); + + test("does not run a second transaction when a failed command leaves the verified old group active", () => { + const owner = ownerFor(); + const state: FakePnpmState = { activeVersion: "1.0.0", failVersions: new Set(["2.0.0"]), calls: [] }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: false, phase: "install", rolledBack: true, activePath: owner.packagePath }); + expect(state.calls.filter(call => call.args[0] === "add")).toHaveLength(1); + }); + + test("rolls back when a zero-exit install leaves an invalid tree or stale shim", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidVersions: new Set(["2.0.0"]), + invalidShimVersions: new Set(["2.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ + ok: false, phase: "rollback", rolledBack: true, activePath: owner.packagePath, + }); + expect(state.calls.filter(call => call.args[0] === "add").map(call => call.args.at(-1))).toEqual([ + `${PKG}@2.0.0`, `${PKG}@1.0.0`, + ]); + }); + + test("does not claim rollback when the restored group cannot be verified", () => { + const owner = ownerFor(); + const state: FakePnpmState = { + activeVersion: "1.0.0", + invalidVersions: new Set(["2.0.0"]), + failVersions: new Set(["1.0.0"]), + calls: [], + }; + const result = runPnpmGlobalUpdate({ + packageName: PKG, currentVersion: "1.0.0", targetVersion: "2.0.0", tag: "latest", owner, + runPnpm: fakePnpm(state, owner), verify: fakeVerify(state, owner), verifyShims: fakeShims(state), + }); + expect(result).toMatchObject({ ok: false, phase: "rollback", rolledBack: false }); + expect((result as { activePath?: string }).activePath).toBeUndefined(); + }); +}); + +describe("shared registry integrity pre-flight", () => { + test("fails closed only for successful metadata without sha512 and skips query failures", () => { + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ + status: 0, + stdout: '"sha512-abc="', + }))).toEqual({ ok: true, integrity: "sha512-abc=" }); + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 0, stdout: "sha1-deprecated" })).ok).toBe(false); + expect(checkRegistryPackageIntegrity(PKG, "2.0.0", () => ({ status: 1 })).ok).toBe("skipped"); + }); + + test("both launcher and Bun worker use the shared helper before stopping", () => { + const launcher = readFileSync(join(dirname(import.meta.dir), "..", "bin", "ocx.mjs"), "utf8"); + const update = readFileSync(join(dirname(import.meta.dir), "..", "src", "update", "index.ts"), "utf8"); + expect(launcher).toContain("checkRegistryPackageIntegrity"); + expect(launcher.indexOf("checkRegistryPackageIntegrity")).toBeLessThan(launcher.indexOf("Stopping the running proxy")); + expect(update).toContain("checkRegistryPackageIntegrity"); + }); +}); + +describe("pnpm update command", () => { + test("uses pnpm's native global build approval and pins resolved versions", () => { + expect(updateCommand("pnpm", "latest", "2.50.0")).toEqual({ + bin: "pnpm", + args: ["add", "-g", "--allow-build=bun", `${PKG}@2.50.0`], + }); + expect(updateCommandStr("pnpm", "latest", "2.50.0")).toContain("pnpm add -g --allow-build=bun"); + expect(updateCommand("pnpm", "latest").args.at(-1)).toBe(`${PKG}@latest`); + }); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index f52c53a008..7fa117758b 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -524,7 +524,7 @@ describe("update stops the running proxy before replacing files", () => { test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => { expect(updateSource).toContain('spawnSync(process.execPath, selfLaunchArgv(["stop"])'); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); - const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args"); + const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args", stopAt); expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); expect(stopAt).toBeLessThan(updateAt); @@ -532,7 +532,7 @@ describe("update stops the running proxy before replacing files", () => { }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { - const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest);"); + const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);"); const abortAt = updateSource.indexOf("aborting the update before stopping the proxy"); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); expect(gateAt).toBeGreaterThan(-1); @@ -568,14 +568,16 @@ describe("update stops the running proxy before replacing files", () => { expect(launcherSource).toContain('existsSync(join(configDir(), "runtime-port.json"))'); }); - test("Windows npm paths resolve safely before stop and never use shell:true", () => { - const updateResolveAt = updateSource.indexOf("const target = updateSpawnTarget(bin, cmdArgs);"); + test("Windows package-manager paths resolve safely before stop and never use shell:true", () => { + const updateResolveAt = updateSource.indexOf("const target = installer === \"pnpm\" && owner"); const updateStopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); - const launcherResolveAt = launcherSource.indexOf("const installInvocation = npmInvocation("); + const launcherResolveAt = launcherSource.indexOf("const installInvocation = managerInvocation(installArgs);"); const launcherStopAt = launcherSource.indexOf('[launcher, "stop"]'); expect(updateResolveAt).toBeGreaterThan(-1); expect(launcherResolveAt).toBeGreaterThan(-1); + expect(launcherSource).toContain("resolvePnpmGlobalOwner"); + expect(launcherSource).toContain("pnpmOwnerInvocation(owner, args)"); expect(updateResolveAt).toBeLessThan(updateStopAt); expect(launcherResolveAt).toBeLessThan(launcherStopAt); expect(updateSource).not.toContain("shell: true"); @@ -653,7 +655,9 @@ describe("update stops the running proxy before replacing files", () => { writeFileSync(join(opencodexHome, "runtime-port.json"), JSON.stringify({ port, pid: 999_999_999 })); writeFileSync(fakeNpm, `#!/bin/sh case "$1" in - view) printf '2.0.0\\n' ;; + view) + if [ "$3" = "dist.integrity" ]; then printf 'sha512-testfixturevalue000000000\\n'; else printf '2.0.0\\n'; fi + ;; config) printf '%s\\n' "$OCX_FAKE_NPM_CACHE" ;; install) exit 1 ;; *) exit 1 ;; diff --git a/tests/update/update-tree-ownership.test.ts b/tests/update/update-tree-ownership.test.ts new file mode 100644 index 0000000000..40d829fa9d --- /dev/null +++ b/tests/update/update-tree-ownership.test.ts @@ -0,0 +1,233 @@ +/** + * #4202 review (Ingwannu, blocking on PR #4203): the pnpm path may resolve a dependency + * outside the package directory, but the npm verifier must stay confined to the candidate's + * own tree. Node's resolver walks the ancestor chain, so a global npm candidate can otherwise + * satisfy its bundled-Bun requirement from a sibling package's install. Three decisions read + * that verdict — accepting the stage, rolling back after the swap, and reaping the only + * backup at boot — so a non-self-contained candidate called healthy costs the known-good copy. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + bootRestoreProbe, + verifyInstallTree, + verifyPnpmInstallTree, +} from "../../src/update/transactional-install.mjs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const PKG = "@bitkyc08/opencodex"; +const BUN_BYTES = 10 * 1024 * 1024 + 1024; + +/** A dependency directory that would satisfy the manifest if it were ever consulted. */ +function writeDependency(dir: string, name: string, opts: { truncated?: boolean } = {}): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name })); + if (name === "bun") { + writeFileSync(join(dir, "bun.exe"), Buffer.alloc(opts.truncated ? 1024 : BUN_BYTES)); + } +} + +/** The package itself, with no dependencies of its own unless the caller adds them. */ +function writePackage(packageDir: string, version: string): void { + mkdirSync(join(packageDir, "bin"), { recursive: true }); + mkdirSync(join(packageDir, "node_modules"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, version, dependencies: { bun: "1", zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); +} + +describe("#4202 install-tree dependency ownership", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-tree-ownership-")); + }); + + afterEach(() => { + removeTreeWithRetry(root); + }); + + /** Global npm layout: /lib/node_modules/{@scope/pkg,bun,zod}. */ + function globalNpmFixture(opts: { ownBun?: "intact" | "truncated" } = {}): string { + const globalRoot = join(root, "lib", "node_modules"); + const packageDir = join(globalRoot, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + // An unrelated global installation that happens to bundle the same dependencies. + writeDependency(join(globalRoot, "bun"), "bun"); + writeDependency(join(globalRoot, "zod"), "zod"); + if (opts.ownBun) { + writeDependency(join(packageDir, "node_modules", "bun"), "bun", { + truncated: opts.ownBun === "truncated", + }); + } + return packageDir; + } + + test("an npm candidate missing its own dependencies is not saved by an ancestor install", () => { + const packageDir = globalNpmFixture(); + const result = verifyInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + expect(result.failures).toContain("sentinel dependency missing: zod"); + }); + + test("an npm candidate with a truncated own Bun is not rescued by an intact ancestor Bun", () => { + const packageDir = globalNpmFixture({ ownBun: "truncated" }); + const result = verifyInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("bundled Bun binary missing or truncated (< 10MB)"); + // zod still has no copy inside the candidate, and the ancestor's does not count. + expect(result.failures).toContain("sentinel dependency missing: zod"); + }); + + test("a self-contained npm candidate still verifies", () => { + const packageDir = globalNpmFixture({ ownBun: "intact" }); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + expect(verifyInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("a half-extracted Bun directory is size-gated even when Bun is not a sentinel", () => { + // Sentinels are the bun/zod subset when it is non-empty, so a manifest that declares + // zod but not bun leaves bun out of the sentinel loop entirely. The size gate has to + // key on the directory, as it did before the pnpm carry, or a truncated binary with no + // package.json rides through and the tree is called healthy. + const packageDir = join(root, "lib", "node_modules", ...PKG.split("/")); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ + name: PKG, version: "2.0.0", dependencies: { zod: "1" }, + })); + writeFileSync(join(packageDir, "bin", "ocx.mjs"), "#!/usr/bin/env node\n" + "x".repeat(2048)); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + // Interrupted extraction: the binary landed, the manifest did not. + mkdirSync(join(packageDir, "node_modules", "bun"), { recursive: true }); + writeFileSync(join(packageDir, "node_modules", "bun", "bun.exe"), Buffer.alloc(1024)); + + const result = verifyInstallTree(packageDir, "2.0.0"); + + expect(result.ok).toBe(false); + expect(result.failures).toContain("bundled Bun binary missing or truncated (< 10MB)"); + }); + + test("boot restore keeps the backup when the live tree only resolves through an ancestor", () => { + // Live tree in a global npm layout, its dependencies supplied only by the sibling install. + const globalRoot = join(root, "lib", "node_modules"); + const scopeDir = join(globalRoot, "@bitkyc08"); + const packageDir = join(scopeDir, "opencodex"); + writePackage(packageDir, "2.0.0"); + writeDependency(join(globalRoot, "bun"), "bun"); + writeDependency(join(globalRoot, "zod"), "zod"); + // A known-good backup from the previous swap, sitting where bootRestoreProbe looks. + const backup = join(scopeDir, ".ocx-backup-2026-01-01T00-00-00-000Z", "opencodex"); + writePackage(backup, "1.0.0"); + writeDependency(join(backup, "node_modules", "bun"), "bun"); + writeDependency(join(backup, "node_modules", "zod"), "zod"); + + const probe = bootRestoreProbe(packageDir); + + expect(probe.action).toBe("restored"); + expect(existsSync(join(packageDir, "node_modules", "bun", "package.json"))).toBe(true); + }); + + test("boot restore still reaps the backup for a genuinely self-contained live tree", () => { + const scopeDir = join(root, "lib", "node_modules", "@bitkyc08"); + const packageDir = join(scopeDir, "opencodex"); + writePackage(packageDir, "2.0.0"); + writeDependency(join(packageDir, "node_modules", "bun"), "bun"); + writeDependency(join(packageDir, "node_modules", "zod"), "zod"); + const backupRoot = join(scopeDir, ".ocx-backup-2026-01-01T00-00-00-000Z"); + writePackage(join(backupRoot, "opencodex"), "1.0.0"); + + const probe = bootRestoreProbe(packageDir); + + expect(probe.action).toBe("reaped"); + expect(existsSync(backupRoot)).toBe(false); + }); + + test("the pnpm verifier refuses an ancestor root that carries no pnpm bookkeeping", () => { + // Same shape as the npm escape: a bare ancestor node_modules is somebody else's install. + const packageDir = globalNpmFixture(); + const result = verifyPnpmInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + }); + + test("the pnpm verifier accepts a hoisted group that pnpm's own metadata claims", () => { + const groupRoot = join(root, "global", "v11", "node_modules"); + const packageDir = join(groupRoot, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(groupRoot, "bun"), "bun"); + writeDependency(join(groupRoot, "zod"), "zod"); + writeFileSync(join(groupRoot, ".modules.yaml"), "nodeLinker: hoisted\n"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("the pnpm verifier accepts a virtual-store link reached through the package's own tree", () => { + const store = join(root, "store", "v11", "node_modules", ".pnpm", "registry", "node_modules"); + const packageDir = join(root, "global", "v11", "node_modules", ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(store, "bun"), "bun"); + writeDependency(join(store, "zod"), "zod"); + // pnpm's isolated linker links each declared dependency into the package's node_modules. + symlinkSync(join(store, "bun"), join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(join(store, "zod"), join(packageDir, "node_modules", "zod"), "dir"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("the npm verifier accepts the same virtual-store link, because the candidate owns it", () => { + // The link lives inside the candidate's own node_modules, which is the npm contract too. + const store = join(root, "store", "node_modules"); + const packageDir = join(root, "global", "node_modules", ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(store, "bun"), "bun"); + writeDependency(join(store, "zod"), "zod"); + symlinkSync(join(store, "bun"), join(packageDir, "node_modules", "bun"), "dir"); + symlinkSync(join(store, "zod"), join(packageDir, "node_modules", "zod"), "dir"); + expect(verifyInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("a package root that is itself a pnpm symlink resolves through its realpath", () => { + const target = join(root, "store", "v11", "node_modules", ".pnpm", "pkg", "node_modules", ...PKG.split("/")); + writePackage(target, "2.0.0"); + writeDependency(join(target, "node_modules", "bun"), "bun"); + writeDependency(join(target, "node_modules", "zod"), "zod"); + const exposed = join(root, "global", "v11", "node_modules", ...PKG.split("/")); + mkdirSync(join(root, "global", "v11", "node_modules", "@bitkyc08"), { recursive: true }); + symlinkSync(target, exposed, "dir"); + expect(verifyPnpmInstallTree(exposed, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("the pnpm verifier accepts the default isolated store, where deps are siblings", () => { + // pnpm's isolated linker puts each dependency of X beside X inside + // .pnpm/@/node_modules, not inside X/node_modules, and the physical + // dependency lives in its own .pnpm/@ entry. The dependency is therefore + // neither in the package's own tree nor a child of the group root, which is why + // ownership has to be probed through the link farm rather than the resolved realpath. + const virtualStore = join(root, "global", "v11", "node_modules", ".pnpm"); + const instance = join(virtualStore, "@bitkyc08+opencodex@2.0.0", "node_modules"); + const packageDir = join(instance, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + writeDependency(join(virtualStore, "bun@1.0.0", "node_modules", "bun"), "bun"); + writeDependency(join(virtualStore, "zod@1.0.0", "node_modules", "zod"), "zod"); + symlinkSync(join(virtualStore, "bun@1.0.0", "node_modules", "bun"), join(instance, "bun"), "dir"); + symlinkSync(join(virtualStore, "zod@1.0.0", "node_modules", "zod"), join(instance, "zod"), "dir"); + expect(verifyPnpmInstallTree(packageDir, "2.0.0")).toEqual({ ok: true, failures: [] }); + }); + + test("a sibling entry in the same virtual store cannot vouch for an unrelated group", () => { + // The instance directory is per package@version, so a dependency parked in a DIFFERENT + // instance's link farm is not reachable from this one and must not satisfy it. + const virtualStore = join(root, "global", "v11", "node_modules", ".pnpm"); + const instance = join(virtualStore, "@bitkyc08+opencodex@2.0.0", "node_modules"); + const packageDir = join(instance, ...PKG.split("/")); + writePackage(packageDir, "2.0.0"); + const otherInstance = join(virtualStore, "something-else@1.0.0", "node_modules"); + writeDependency(join(otherInstance, "bun"), "bun"); + writeDependency(join(otherInstance, "zod"), "zod"); + const result = verifyPnpmInstallTree(packageDir, "2.0.0"); + expect(result.ok).toBe(false); + expect(result.failures).toContain("sentinel dependency missing: bun"); + }); +});