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/260910_250_regression_audit_release/000_plan.md b/devlog/_plan/260910_250_regression_audit_release/000_plan.md new file mode 100644 index 0000000000..604eaa2a17 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/000_plan.md @@ -0,0 +1,57 @@ +# 2.50.0 regression audit and release — scope + +## Baseline and candidate + +- Released baseline: `v2.49.0`, `main` at `2f3f736299dca38861f8fb9c4326a4b4d7c664bc`. +- Audit candidate: `origin/dev` at `12c248f52bed88ea13be5b284c79a238feb592d1`, `package.json` version `2.50.0`. +- Delta: 127 commits (`git rev-list --count 2f3f73629..origin/dev`), 1066 changed files + (`git diff --name-only 2f3f73629...origin/dev | wc -l`). + +### File counts, by `git diff --name-only 2f3f73629...origin/dev | cut -d/ -f1 | sort | uniq -c` + +| Group | Files | Note | +| --- | --- | --- | +| `devlog` | 885 | No runtime. Not audited. | +| `src` | 62 | Audited: L1-L6 (L4 owns `src/server/management/*`, `src/server/{management-api,auth-cors,index}.ts`, `src/service.ts`) | +| `tests` | 42 | Read as evidence by every lane, not a lane of its own | +| `docs-site` | 28 | Not release-blocking on its own | +| `gui` | 27 | Audited: L4 | +| `readme` + root docs | 13 | `readme/` 8, plus `README.md`, `AGENTS.md`, `AGENTS_INSTALL.md`, `SECURITY.md`, `package.json` | +| `skills` | 3 | Audited: L6 (`skills/ocx` surface map) | +| `.github` | 3 | PR assets only, no workflow change | +| `structure` | 2 | Maintainer invariants | +| `scripts` | 1 | `scripts/test-layout/layout.json` | + +`git diff --shortstat 2f3f73629...origin/dev -- src gui docs-site scripts .github` is +121 files / +3322 / -280. That figure excludes `tests` and `package.json`; the full +non-`devlog` set is 181 files. An earlier revision of this doc attributed 121 to a +different folder set and derived the devlog count by subtraction; the reviewer +contradicted both with the commands above. + +## What this unit does + +Audit the product delta for release-blocking regressions, remediate anything blocking, +then run the 2.50.0 train: pre-move `dev`, promote the frozen candidate to `main`, +publish to npm, and verify the artifacts independently. + +## Authorization in force + +The user authorized parallel `xai/grok-4.6` subagents, a regression-audit PABCD cycle, +and the release itself. Subagents are read-only verifiers; the main session owns every +PABCD transition, every write, and every external action. + +## Out of scope + +- Landing unrelated open pull requests. 74 are open against `dev` + (`gh api "repos/lidge-jun/opencodex/pulls?state=open&base=dev&per_page=100" --jq 'length'`); + none is a release prerequisite, and pulling one in moves the candidate mid-audit. +- Re-auditing anything already released in 2.49.0. +- Any change to `devlog/` history or to third-party accounts. + +## Terminal outcomes + +- `DONE` — 2.50.0 on npm `latest` with `gitHead` matching the promoted `main` SHA, a + git tag, a GitHub release, and a recorded triage for every audit finding. +- `BLOCKED` — a release-blocking regression that cannot be fixed inside this scope, + or a missing external permission (npm trusted publishing, workflow dispatch). +- `NOOP` — the candidate is already published and verified. diff --git a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md new file mode 100644 index 0000000000..f469ec2e9d --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md @@ -0,0 +1,98 @@ +# Audit lanes + +Six read-only lanes, each dispatched to an independent `xai/grok-4.6` subagent with a +fresh context. Read scopes are stated per lane so a finding traces to one owner; the +lanes never write, and the main session de-duplicates the returns. + +Every lane compares `2f3f73629...origin/dev` and must return exact `path:line` anchors. +A lane that finds nothing returns "no blocker" with the files it actually read. + +Lane coverage is checked mechanically: every path in +`git diff --name-only 2f3f73629...origin/dev -- src gui scripts skills package.json` +belongs to at least one lane. The first revision of this map left +`src/cli/{capabilities,index,models-runtime,observe}.ts` unowned, which is why L6 exists. + +## L1 — Responses and request pipeline + +`src/server/responses/{core,compact,context-overflow,policy-fallback,codex-ws-wire}.ts`, +`src/server/{chat-completions,chat-native,claude-messages,images,search,request-decompress,request-log}.ts`, +`src/claude/inbound.ts`, `src/web-search/{passthrough-bridge,ollama-executor}.ts`. + +Highest-risk lane: the new hosted web-search bridge (`passthrough-bridge.ts` +761), the +non-streaming context-overflow classification, agent-task recovery on mid-thread model +switches, and the configurable inbound body admission limit. + +## L2 — Codex accounts, quota, OAuth + +`src/codex/{account-runtime-state,account-store,account-usability,auth-api,auth-context,inject,quota,quota-auto-refresh}.ts`, +`src/oauth/{health,index,token-guardian}.ts`, `src/cli/{account,account-api,account-auth,account-extended}.ts`. + +Deferred validation, revoked pool grants, reauth-state clearing, the new account plan +field, and the token guardian. + +## L3 — Catalog, providers, combos, config + +`src/codex/catalog/{parsing,provider-fetch,sync}.ts`, `src/providers/{registry,quota,google-ai-studio-model-discovery,opencode-zen-rate-limit}.ts`, +`src/combos/{index,resolve}.ts`, `src/config.ts`, `src/types.ts`, `src/types/{accounts,config,provider}.ts`, +`src/clients/config-export/zcode.ts`, `src/lib/errors.ts`. + +Free-model pricing classification and filtering, quota-exhausted inactive marking, AI +Studio discovery restoration, cross-provider blocked-model redirects. + +## L4 — Management API, service, GUI + +`src/server/management/*`, `src/server/{management-api,auth-cors,index}.ts`, `src/service.ts`, +`gui/src/**`, `gui/tests/**`. + +The routed-account log label, the decode-rate column, management auth, the stale launchd +bootout recovery, and nine i18n locale files that must not contradict `en`. + +## L5 — Security, privacy, release surface + +Cross-cutting read of `src/lib/privacy.ts`, the body-size admission path, web-search +bridge egress, `package.json`, +`scripts/test-layout/layout.json`, `structure/{02_config-and-codex-home,04_transports-and-sidecars}.md`, +and the repository invariants in `AGENTS.md`: the Lab/core import boundary, the +synchronous `startServer` window, no tracked gitlink, and no request-body or credential +logging. + +The email-masking opt-out is the specific item to scrutinize: it deliberately weakens a +privacy default, so it must be off by default, must survive `bun run privacy:scan`, and +its CLI application in `src/cli/index.ts` (L6) must agree with the library default. + +## L6 — Operator CLI surface + +`src/cli/{capabilities,index,models-runtime,observe}.ts`, `skills/ocx/**`, and the +generated surface map that `tests/ci-workflows/skill-ocx.test.ts` asserts. + +`src/cli/index.ts` applies `privacy.maskEmails` to `ocx status` and is a +`service-lifecycle.yml` gate path. `capabilities.ts` adds a mutating `ocx account refresh` +with a consent warning. `models-runtime.ts` adds `--free-only`, which filters on +`pricingStatus === "free"` and therefore drops entries with no status. `observe.ts` adds +`--account` log filtering. + +## Blocker definition + +A finding blocks the release when any of these hold. + +1. **Regression against 2.49.0** — behavior that worked in the released tree and does not now. +2. **Crash, hang, or unbounded resource use** on any path a default install can reach. +3. **New-path functional breakage.** A feature introduced in this delta that does not do + what it claims still blocks, even though it is not a regression. This covers the + web-search bridge returning wrong or empty results, `--free-only` silently dropping + models with an absent `pricingStatus`, and a no-op `ocx account refresh`. +4. **Security or privacy weakening**, including a default that becomes less private, a + credential or request body reaching a log, or a loosened auth boundary. +5. **User-consent or identity-spend bypass**, per `AGENTS.md` "User-consent actions": any + path that spends the user's identity, credits, or reputation without the code-level + gate, including a CLI path that mints its own dashboard session. +6. **Core invariant violation**, per `AGENTS.md`: a Lab import reaching `src/router.ts`, + `src/server/lifecycle.ts`, or `src/server/responses/core.ts`, an `await` inside the + synchronous `startServer` activation window, or a tracked gitlink. +7. **Upgrade-path breakage**, not only first-run. An existing 2.49.0 install that keeps a + stale launchd job, a stale config, or a stale service unit after upgrading blocks. +8. **Broken release, packaging, or operator-surface contract**, including a `skills/ocx` + map that names a command the registry does not have. + +Style, missing coverage for unchanged code, and defects that already shipped in 2.49.0 +do not block; they are recorded as non-blockers with the evidence that they predate the delta. diff --git a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md new file mode 100644 index 0000000000..a96f9faca2 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md @@ -0,0 +1,77 @@ +# 2.50.0 release plan + +Derived from `.github/workflows/release.yml` as it exists on `dev`, not from precedent. +The gates below are what the workflow actually enforces. + +## What release.yml requires + +| Gate | Line | Requirement | +| --- | --- | --- | +| Branch | `release.yml:153-170` | Must run from `refs/heads/main` or `refs/heads/preview`. `main` refuses any version containing `-`; `preview` refuses any version that is not `*-preview.*`. | +| dist-tag | `release.yml:174-177` | `main` -> `latest`, `preview` -> `preview`. | +| CI | `release.yml:179-197` | A **successful `ci.yml` run with `--event push` on the release branch for `$GITHUB_SHA`**. A pull-request run is explicitly rejected, and a `workflow_dispatch` run on `dev` does not qualify. | +| Service lifecycle | `release.yml:225-237` | If any of `src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, `service-lifecycle.yml`, `release.yml` changed since the previous tag, a successful `service-lifecycle.yml` run for `$GITHUB_SHA` is required. This delta changes `src/cli/index.ts` and `package.json`, so the gate is armed. | +| dev ahead | `release.yml:242-249` | `bun scripts/version-line.ts assert-ahead `. `dev` is currently `2.50.0`, so publishing 2.50.0 fails until `dev` is pre-moved. | +| Publish | `release.yml:22-26` | `dry-run` defaults to **true**. A real publish needs `dry-run=false`. `expected-sha` is required and must equal the branch head at dispatch. | + +`$GITHUB_SHA` on `main` is the **promotion merge commit**, not the frozen `dev` SHA. +2.49.0 published from merge `2f3f73629`, not from its promoted tree commit `62849dfa6`. +Both `ci.yml` (`push: branches: [main, preview, dev]`, `paths: src/**, gui/**, ...`) and +`service-lifecycle.yml` (`push`, paths including `package.json` and `src/cli/index.ts`) +fire automatically on that merge, so the required runs appear without a dispatch — but +they must be waited for on that exact SHA. + +## Order + +1. **Freeze the candidate.** Record the exact `dev` SHA. A `workflow_dispatch` `lane=all` + run on `dev` is audit evidence for the tree, not the release gate; it tells us whether + the tree is green before we spend a promotion on it. +2. **Land blockers first.** Any wp3 fix goes to `dev` through a pull request, which moves + the candidate. Re-freeze and re-verify on the new SHA; old-head green is not evidence. +3. **Pre-move `dev`.** Dispatch `dev-version-bump.yml` with `intended-version=2.50.0`, + `mode=pre-move`. It is `on: workflow_dispatch`, but `dev-version-bump.yml:79` refuses + a non-default ref, so dispatch it with `--ref main`. It opens a pull request and does + **not** push to `dev`, because the `Protect dev` ruleset requires review. Merge that PR + so `dev` reads 2.51.0 before the publish reaches `assert-ahead`. Use the workflow rather + than a hand-written one-file PR so its tag/npm/version-line proofs run. +4. **Promote the frozen SHA to `main`, not current `dev`.** After step 3, `origin/dev` is + 2.51.0 and is no longer the candidate. Promotion always names the recorded freeze SHA + explicitly. + + The freeze SHA is **not** an ancestor of `main`, and `main` carries commits `dev` does + not, so there is nothing to fast-forward. Replicate the 2.49.0 method: branch from + `main`, merge the freeze SHA into that branch as a single + `release: promote verified 2.50.0 product tree to main` commit, then open the PR into + `main`. For 2.49.0 that was branch `codex/release-249-main-01a08498`, promote commit + `62849dfa6` (parents `9a27e8699` = old `main`, `ad36c7be8` = the dev freeze), merged by + PR #4117 as `2f3f73629`. + + The gate on this step is **tree equality**, not a green diff: after promotion, + `git rev-parse
^{tree}` must equal `git rev-parse ^{tree}`. + For 2.49.0 all three of the promote commit, the dev freeze, and the merged `main` tip + resolved to tree `66294fb3eb15592afd732f8b8e29d0bcc644fe9e`. Any conflict resolution + that changes that tree means a different product shipped than the one audited. + Record the merge SHA. +5. **Wait for the release-branch gates on the merge SHA.** Push-event `ci.yml` and + `service-lifecycle.yml` on `main` for that exact SHA, both successful. +6. **Dry-run, then publish.** Dispatch `release.yml` with `--ref main`, + `version=2.50.0`, `tag=latest`, `expected-sha=`, first with + `dry-run=true`, then with `dry-run=false` once the dry run is green. +7. **Verify artifacts.** `npm view @bitkyc08/opencodex dist-tags`, the `2.50.0` + `gitHead` against the promoted `main` SHA, the git tag, the GitHub release, tarball + integrity, and SLSA provenance. npm propagation lag returns 404 or a stale `latest`; + poll, never republish. +8. **`preview` is a separate line and is not part of this stable train.** + `origin/preview` is `2.49.0-preview.20260909`, and `release.yml:161-165` refuses a + preview publish whose version is not `*-preview.*`. Promoting the plain `2.50.0` tree + onto `preview` would break that branch's version line. If `preview` should carry this + tree, it needs its own `2.50.0-preview.` commit, decided after the stable + release lands. A branch sync and a preview npm publication are distinct operations. + +## Known failure modes to expect + +- Branch-keyed CI concurrency cancels an older run when a newer commit lands. A cancelled + aggregate is neither a product failure nor passing evidence. +- The registry-availability smoke can time out after npm already accepted the publish. + Inspect metadata, provenance, and tarball before considering a retry. +- `dev-version-bump.yml` rejects a dispatch from a non-default ref as an early warning. diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md new file mode 100644 index 0000000000..5d70374aea --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/030_evidence.md @@ -0,0 +1,146 @@ +# Evidence ledger + +Filled as the cycles complete. Every row names the source of the claim. + +## Frozen facts + +| Item | Value | Source | +| --- | --- | --- | +| Released baseline | `v2.49.0` / `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | `git log origin/main` | +| Audit candidate | `dev` `12c248f52bed88ea13be5b284c79a238feb592d1` | `git rev-parse origin/dev` | +| Candidate version | `2.50.0` | `package.json` | +| Commits in delta | 127 | `git rev-list --count 2f3f73629..origin/dev` | +| Changed files | 1066 total, 885 `devlog`, 181 non-`devlog` | `git diff --name-only 2f3f73629...origin/dev` | +| `preview` version line | `2.49.0-preview.20260909` | `git show origin/preview:package.json` | +| Open PRs against `dev` | 74 | `gh api "repos/lidge-jun/opencodex/pulls?state=open&base=dev&per_page=100" --jq 'length'` | + +## wp1 — roadmap audit (A gate) + +Reviewer: `xai/grok-4.6`, agent `01a08a86-1352-77a2-98bd-5c167b5479c8`, read-only, fresh context. +Verdict: **FAIL**. Every finding was verified independently by the main session before folding. + +| # | Finding | Verified by | Fold | +| --- | --- | --- | --- | +| R1 | `src/cli/{capabilities,index,models-runtime,observe}.ts` belonged to no lane | `git diff --name-only` vs the lane map | Lane **L6** added | +| R2 | Blocker definition missed new-path breakage, consent/identity-spend bypass, `AGENTS.md` core invariants, upgrade-path recovery, and operator-surface drift | `AGENTS.md:43-83`, `AGENTS.md:150-169` | Definition rewritten to 8 clauses | +| R3 | `release.yml:179-197` needs a push-event `ci.yml` run on the release branch for `$GITHUB_SHA`; a `dev` dispatch does not qualify | `sed -n '179,197p' .github/workflows/release.yml` | Order rewritten: gates run on the `main` merge SHA | +| R4 | `service-lifecycle.yml` is gated on `$GITHUB_SHA`, and this delta arms it via `src/cli/index.ts` + `package.json` | `sed -n '225,237p' .github/workflows/release.yml` | Made an explicit step on the merge SHA | +| R5 | `preview` refuses a non-`*-preview.*` version, and `origin/preview` is `2.49.0-preview.20260909` | `sed -n '161,165p' release.yml`; `git show origin/preview:package.json` | `preview` removed from the stable train | +| R6 | `dev-version-bump.yml` is `on: workflow_dispatch`, not `workflow_call`-only | `sed -n '24,45p' .github/workflows/dev-version-bump.yml` | Pre-move now uses the workflow, not a hand PR | +| R7 | `dry-run` defaults to `true` and the run must come from `refs/heads/main` | `sed -n '22,26p'`, `sed -n '153,170p'` `release.yml` | Dry-run-then-publish made explicit | +| R8 | Scope doc misattributed the 121-file figure, derived the devlog count by subtraction, and said 20 open PRs | `git diff --shortstat`; `gh api ... --jq 'length'` -> 74 | Counts table rewritten from the real command | + +Round 2 verdict: **GO-WITH-FIXES**. R1-R8 all confirmed FIXED with anchors, and the +mechanical lane-coverage check over the 94 changed product paths returned zero unlaned. +Three new findings were raised and folded: + +| # | Finding | Verified by | Fold | +| --- | --- | --- | --- | +| R9 | After the pre-move, `origin/dev` is 2.51.0; promoting current `dev` would publish the wrong version. The plan never pinned the promotion source to the freeze SHA | `020_release_plan.md:37` as written | Step 4 now names the recorded freeze SHA explicitly | +| R10 | The freeze SHA is not an ancestor of `main` and `main` carries commits `dev` lacks, so a naive `base=main head=` PR is a 127-commit history merge rather than a tree promotion | `git merge-base --is-ancestor 12c248f52 origin/main` -> 1 | Step 4 documents the 2.49.0 branch-and-merge method and makes **tree equality** the gate: promote tree, dev freeze tree, and merged `main` tree all resolved to `66294fb3eb15592afd732f8b8e29d0bcc644fe9e` for 2.49.0 | +| R11 | `000_plan.md` said `src` is audited by L1-L3, L5, L6, but L4 owns `src/server/management/*` and `src/service.ts` | `010_audit_lanes.md:44` | Counts table corrected to L1-L6 | + +Also folded from the round-2 residual: `dev-version-bump.yml:79` refuses a non-default +ref, so the pre-move dispatch must use `--ref main`; and L5 no longer names OrcaRouter +key-exchange bounds, which are not in this delta. + +## Audit findings (wp2) + +Six `xai/grok-4.6` lanes, dispatched in one round, fresh context each, read-only. +**All six returned `NO-BLOCKER`.** No finding matched any of the eight blocker clauses. + +| Lane | Agent | Verdict | Files read | +| --- | --- | --- | --- | +| L1 responses / web-search | `01a08aa6-5954-76f1-a205-f4a85b76457f` | NO-BLOCKER | 31 | +| L2 accounts / quota / OAuth | `01a08aa6-59f5-79d3-a567-556a80d05c84` | NO-BLOCKER | 23 | +| L3 catalog / providers / config | `01a08aa6-5aa1-7132-b053-776bb02b0fe7` | NO-BLOCKER | 36 | +| L4 management / service / GUI | `01a08aa6-5b57-7343-9e1c-b4b3ea186478` | NO-BLOCKER | 41 | +| L5 security / privacy / release | `01a08aa6-5c11-7c52-b4db-6ec685159a33` | NO-BLOCKER | 40 | +| L6 operator CLI | `01a08aa6-5ccd-76c0-af89-83cf0ea80e28` | NO-BLOCKER | 34 | + +### Non-blocking findings, with dispositions + +| ID | Lane | Anchor | What it is | Disposition | +| --- | --- | --- | --- | --- | +| F1 | L1 | `src/web-search/passthrough-bridge.ts:503` | If the upstream emits a `web_search` function call and then `response.failed`/`incomplete`, `decide()` ends without `searchEndFrames`, so a client can keep a "Searching the web" cell open under a failed turn. The explicit `kind === "fail"` path does close it. Opt-in bridge only, default off. | `SHIP` — cosmetic, on a feature that must be explicitly enabled | +| F2 | L3 | `src/codex/catalog/provider-fetch.ts:1415` | Classification reads `pricing.prompt`/`completion` only, so a row with both at zero plus a paid `pricing.request`/`image`/`web_search` key would classify `free`. No in-tree fixture has that shape. | `SHIP` — `RUNTIME-CHECK` resolved: `pricingStatus` is consumed only by `src/cli/models-runtime.ts:68` and `gui/src/pages/models-shared.ts:85` as a display filter. It gates no routing and no spend, so the worst case is a mislabelled row, not a charge | +| F3 | L3 | `src/codex/catalog/provider-fetch.ts:1996` | A custom google-adapter gateway returning both `data[]` and a non-Google `models[]` would take the AI Studio parser with zero `generateContent` rows and publish an authoritative empty catalog. The `data[]`-only case is covered by `tests/adapters/google/google-models-listing.test.ts`. | `SHIP` — requires a dual-envelope body no known gateway sends | +| F4 | L2 | `src/oauth/token-guardian.ts:257` vs `src/codex/auth-api.ts:1265` | `isCodexAccountUsable` does not read the persisted terminal flag, so after a restart routing can attempt a dead grant once more. | `PRE-EXISTING` — same process-lifetime pattern as 2.49.0; the guardian that writes the flag is opt-in and default off | +| F5 | L2, L5 | `src/oauth/health.ts:231` | A revoked grant with no persisted terminal and no in-memory reauth can still project healthy after a restart when the guardian never ran. | `PRE-EXISTING` — 2.49.0 behavior; 2.50.0 only adds the `validation_pending` projection, which is strictly more informative | +| F6 | L4 | `src/service.ts:2386` | Install/repair bootout evicts the loaded job, including one that is currently serving, after the plist has been rewritten. | `SHIP` — this is the intended #4141 repair; `startLaunchd` at :2422 still refuses that eviction on the ordinary start path | +| F7 | L6 | `src/cli/capabilities.ts` | `ocx models live --free-only` is a real new flag that is not a declared capability, so it does not reach the generated surface map. | `SHIP` — documentation gap, not the map/registry split that once shipped a phantom `ocx request-history` | +| F8 | CI | `tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts` | `classifies continuous progress stopped by MAX_ITERATIONS as bounded work` timed out at 60s on Windows shard 5/6 of run 34457689927 attempt 1, taking 112.8s. | `PRE-EXISTING` — proved by byte identity against the released tree: `src/codex/log-guard/maintenance.ts` (`81b3a465b`), the test file (`54e83bba2`), and `tests/helpers/remove-tree.ts` (`53e36a584`) are the same blobs at `2f3f73629` and `origin/dev`. Nothing in this delta can have caused it | + +F8 note: `tests/preload.ts` is the one file on that failure path this delta does touch, +and its diff is a comment block with no statement change, so the byte-identity argument is +complete rather than merely true. Attempt 2 of the run passed on rerun. The full reasoning, +including why the timeout is not hardened before the release, is in `060_release_readiness.md`. + +### Independent re-derivation by the main session + +Nothing was accepted on a lane's authority. Re-checked directly: + +| Claim | Command | Result | +| --- | --- | --- | +| Email masking on by default | `rg -n maskEmails src/lib/privacy.ts` | `config?.privacy?.maskEmails !== false` — absent, malformed, and non-boolean all mask | +| Inbound body limit safe default | `rg -n MAX_DECOMPRESSED_BODY_BYTES src/server/request-decompress.ts` | 256 MiB, returned when the configured value is undefined | +| No Lab import in the three core files | `rg -n 'from "[./]*lab/' src/router.ts src/server/lifecycle.ts src/server/responses/core.ts` | no match | +| `startServer` still synchronous | `rg -n 'function startServer' src/server/index.ts` | `export function startServer(...): Server` — not `async` | +| No tracked gitlink | `git ls-files -s \| grep -c '^160000'` | 0; `.gitmodules` absent | +| i18n keys in every locale | per-locale `rg -c` for the four new keys | 6 matches in each of en, ko, de, fr, ja, ru, tr, zh, zh-TW | +| Web-search bridge opt-in | `rg -n webSearchBridge src/` | armed only by `providers..webSearchBridge.enabled` | + +## Release artifacts (wp4) + +| Gate | Evidence | Status | +| --- | --- | --- | +| Candidate-tree CI (`dev` dispatch, audit evidence only) | run 34457689927, `lane=all` on `12c248f52`, attempt 2 conclusion `success` | done | +| Freeze tree to reproduce on `main` | `git rev-parse 12c248f52^{tree}` = `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | recorded | +| `dev` pre-move to 2.51.0 | `dev-version-bump.yml` run 34463313646 opened PR #4194; merged; `origin/dev` = `cf44f6fe887d19f53ede1e09abfe0fe3cf137059`, `package.json` 2.51.0 | done | +| Promotion commit | `3a3de889b6ef3217497f6c5029acf08aec09c0cf`, parents `2f3f73629` (old `main`) and `12c248f52` (freeze), tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | done | +| `main` promotion merge SHA | PR #4195 merged; `origin/main` = `2d4d7a22381a2e497c2442902104619e25f937c7`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, version 2.50.0 | done | +| Push-event Cross-platform CI on merge SHA | run 34464454730, conclusion `success` | done | +| Service lifecycle on merge SHA | run 34464454609, conclusion `success` | done | +| `release.yml` dry run | run 34465317829, `validate-dispatch` and `publish` both `success` | done | +| `release.yml` publish | run 34465442114, `dry-run=false`, `expected-sha=2d4d7a223`; `npm publish --tag latest --access public` printed `+ @bitkyc08/opencodex@2.50.0` | done | +| npm `latest` = 2.50.0 | `npm view @bitkyc08/opencodex dist-tags` -> `{"preview":"2.48.0-preview.20260908","latest":"2.50.0"}` | done | +| `gitHead` matches promoted `main` | `npm view @bitkyc08/opencodex@2.50.0 gitHead` = `2d4d7a22381a2e497c2442902104619e25f937c7`, identical to `origin/main` | done | +| git tag + GitHub release | `git rev-list -n1 v2.50.0` = `2d4d7a223`; release `v2.50.0` published 2026-09-10T10:21:15Z, not a draft, not a prerelease | done | +| Tarball integrity | Downloaded tarball hashes to `sha512-lrcM1sBfjbjqB3h5i2q7A6FbPOXxrdxqhWC7S+w0+oCOZ+9f8ucCgXPt9D2p81dS78ZfYYSJZuDWbU1Ov0VOhQ==`, equal to `dist.integrity`; manifest version 2.50.0; 1094 files, 23,923,744 bytes unpacked | done | +| Published source bytes | `src/lib/privacy.ts`, `src/web-search/passthrough-bridge.ts`, and `src/cli/models-runtime.ts` inside the tarball are SHA-256 identical to the same paths at `2d4d7a223` | done | +| Provenance | Registry attestations are `npm/attestation/tree/main/specs/publish/v0.1` and `slsa.dev/provenance/v1` | done | + +### The registry smoke timed out, and why nothing was republished + +`npm publish` printed `+ @bitkyc08/opencodex@2.50.0` at 10:20:46, and the workflow's +own `Post-publish registry smoke` then failed to read the version back through six bounded +attempts over roughly 27 seconds. It emitted +"npm publish succeeded, but registry verification remains pending; continuing GitHub +release creation without republishing" and proceeded, which is the correct behavior: the +publication receipt already existed. + +The registry served 2.50.0 about 20 minutes after the publish. It was polled, never +republished. This is the documented failure mode — a timed-out availability smoke is not a +failed publish, and republishing on it is how a release gets damaged. + +### `preview` is intentionally not part of this release + +`origin/preview` remains `2.49.0-preview.20260909` and the npm `preview` dist-tag remains +`2.48.0-preview.20260908`. `release.yml:161-165` refuses a preview publish whose version is +not `*-preview.*`, so promoting the plain 2.50.0 tree onto that branch would break its +version line. Bringing `preview` forward needs its own `2.50.0-preview.` commit and +is a separate decision. + +### Gates that failed by design on the promotion PR + +`enforce-target` failed #4195 with "wrong base (main); missing UI screenshot". That gate is +written for contributor pull requests: `main` receives only release promotions, and a +promotion necessarily carries dashboard files while changing no UI of its own. The 2.49.0 +promotion PR #4117 failed the same check and was merged the same way. `AGENTS.md` records +the maintainer promotion exception, and the gates that actually decide are the push-event +runs on the merge SHA, which `release.yml` independently requires. + +Local `prepush` was skipped on the promotion branch. It runs the full ~850-file suite +against a tree byte-identical to one already green on Linux, macOS, and Windows +(`lane=all` run 34457689927), and it was additionally blocked waiting on another Bun test +lock. The remote push-event runs on `2d4d7a223` are the evidence that counts. diff --git a/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md new file mode 100644 index 0000000000..fcc64b9143 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md @@ -0,0 +1,71 @@ +# Triage and remediation protocol (wp3) + +wp2 returns six lane reports. This is how they become a release decision. + +## 1. Normalize + +Each lane return is split into individual findings. A finding is only admitted with an +exact `path:line` anchor or a literal command and its output. An unanchored or misanchored +assertion is recorded as **unsubstantiated** and the main session **must** re-derive it +against the tree. Dropping it undecided is not an option: a real blocker described with a +wrong line number is still a real blocker, and the anchor rule exists to make triage cheap, +not to discard findings. + +Findings from different lanes that name the same defect are merged, keeping every anchor. + +## 2. Classify + +Apply the eight-clause blocker definition in `010_audit_lanes.md`. Each finding gets +exactly one disposition. + +| Disposition | Meaning | Action | +| --- | --- | --- | +| `BLOCK` | Matches a blocker clause | Must be fixed and landed on `dev` before promotion | +| `SHIP` | Real but does not match a clause | Recorded here, filed as an issue if it deserves one, released as is | +| `PRE-EXISTING` | The same user-visible failure was reachable on `2f3f73629` | Not this release's problem; requires the proof below | +| `RUNTIME-CHECK` | Plausible but only decidable by running something | Must be resolved before promotion, by a targeted test, a CI job, or a reasoned rebuttal — never left as a confidence label | +| `WRONG` | The lane misread the code | Rebutted with the anchor that disproves it | + +A finding is `PRE-EXISTING` only when the **user-visible failure** was reachable on the +baseline — not merely that some function it touches already existed. Showing that an old +helper is unchanged proves nothing when a new caller reaches it under new conditions; +clause 3 exists precisely for that case. Acceptable proof is byte identity of every file on +the failure path (`git rev-parse 2f3f73629:` equal to `git rev-parse origin/dev:` +for each), a test that fails on the baseline, or a baseline CI run showing the same failure. + +`BLOCK` may never be downgraded to `SHIP`, and it may only become `PRE-EXISTING` under the +proof above. Weak-proof downgrade is the same evasion as reclassifying to `SHIP`, taken by a +longer route. + +## 3. Remediate + +Every `BLOCK` fix follows the repository's normal contribution path — a branch off the +current `dev`, a focused regression test next to the existing tests for that subsystem, +a pull request against `dev` using `.github/PULL_REQUEST_TEMPLATE.md`, and the exact-head +CI evidence the branch policy requires. No direct push to `dev`; the ruleset rejects it +regardless of `--no-verify`. + +Landing a fix **moves the candidate**. When that happens: + +1. Record the new `dev` SHA as the freeze SHA, superseding `12c248f52`. +2. Re-run the candidate-tree CI dispatch on the new SHA. Green on the old head proves + nothing about the new one. +3. Re-run only the lanes whose read scope intersects the fix, not all six. + +## 4. Escalate rather than weaken + +A `RUNTIME-CHECK` finding that cannot be resolved is treated as a `BLOCK`, not as a +`SHIP`. An unfalsified hang or teardown risk is not evidence of safety. + +If a `BLOCK` cannot be fixed inside this scope — it needs a design decision, an external +credential, or a change the user has not authorized — the release stops and the outcome is +`BLOCKED`. Reclassifying a blocker to `SHIP` to reach a release is the one move this +protocol forbids. The alternative that *is* allowed: revert the offending commit range from +the candidate and release without that feature, which is a smaller change than shipping a +known defect. + +## 5. Record + +Every finding lands in the wp2 findings table in `030_evidence.md` with its ID, lane, +anchor, failure mode, disposition, and — for `BLOCK` — the PR and merge SHA that resolved +it. A finding with no row in that table did not happen. diff --git a/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md new file mode 100644 index 0000000000..bb4706771a --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md @@ -0,0 +1,116 @@ +# Lane dispatch packets (wp2) + +Six `xai/grok-4.6` subagents, dispatched in one round, fresh context each, read-only. +They run concurrently because their questions are independent; none reads another's output. + +## Shared packet frame + +Every packet carries the same frame, with only `SCOPE` and `QUESTIONS` differing. + +- **Repository:** `/Users/jun/.codex/worktrees/b53a/opencodex`, on branch + `codex/260910-250-regression-audit-release`. That branch adds `devlog/` commits on top of + the freeze SHA `12c248f52`; every `src`, `gui`, `tests`, and `scripts` file is identical + to the freeze. **Do not `git checkout` the freeze SHA** — it would detach HEAD on the + worktree we are releasing from. Read `origin/dev` through `git show` if an exact freeze + read is needed. +- **Comparison:** `git diff 2f3f73629...origin/dev -- `. `2f3f73629` is + released `v2.49.0`; the right side is the 2.50.0 candidate. +- **Read the current tree, not only the diff.** A change is often half in the diff and half + in an unchanged caller. Following a symbol into a file outside the lane's diff is expected. +- **MUST NOT:** no writes, edits, commits, pushes, stashes, branch changes, or + `git checkout`/`git switch`/`git restore` of any kind; no test suite, typecheck, build, or + install; no mutating `gh` call. Read-only `git` and `gh api`/`gh run list` only. Do not + fix anything found — report it. +- **PROOF:** every finding needs an exact `path:line` on the candidate side, or a literal + command with its output. Unanchored claims are re-derived by the main session, so an + approximate anchor costs a round trip rather than being silently dropped. +- **RETURN FORMAT:** `VERDICT` (`NO-BLOCKER` or `BLOCKERS-FOUND`), then one numbered entry + per finding with `ANCHOR`, `WHAT BREAKS` (the concrete user-visible failure and the input + that triggers it), `CLAUSE` (a number from the list below, or `non-blocking`), and + `CONFIDENCE` (`certain` / `likely` / `needs-runtime-check`). Then `FILES READ`. +- **DECISION BOUNDARY:** the lane reports evidence and unresolved judgments. It does not + decide whether the release proceeds, does not rank against other lanes, and does not + weaken a finding because it looks hard to fix. + +### The eight blocker clauses, carried inline + +A lane cannot answer `CLAUSE` from a file it was not given, so the list travels with the +packet: (1) regression against 2.49.0; (2) crash, hang, or unbounded resource use on a +reachable default path; (3) new-path functional breakage — a feature added in this delta +that does not do what it claims, even though it is not a regression; (4) security or +privacy weakening; (5) user-consent or identity-spend bypass; (6) core invariant violation +(Lab reaching `src/router.ts`, `src/server/lifecycle.ts`, or `src/server/responses/core.ts`; +an `await` in the synchronous `startServer` activation window; a tracked gitlink); +(7) upgrade-path breakage for an existing 2.49.0 install; (8) broken release, packaging, +or operator-surface contract. + +A lane that finds nothing returns `NO-BLOCKER` and its `FILES READ`. A short honest +return beats a long speculative one. + +## Per-lane questions + +**L1 — responses and request pipeline.** Does the hosted web-search bridge arm only when +opted in, and does a failure fall back rather than hang or leak? Is the search cell placed +in stream order, and are bridge continuations bounded? Does `src/web-search/ollama-executor.ts` +bound its own errors and timeouts? Does the non-streaming context-overflow classification +return a classified reply on every exhausted-target path? Does agent-task recovery on a +mid-thread model switch preserve encrypted content? Does the configurable body admission +limit still have a safe default and reject rather than buffer? In `src/claude/inbound.ts`, +does emitting mid-conversation `role:"system"` as chronological `developer` items change +what the model obeys on an ordinary Claude Code turn? In `src/server/responses/codex-ws-wire.ts`, +what is the cost of the 30s to 90s prelude timeout when the upstream is actually hung? +Does the new `account` filter in `src/server/request-log.ts` match the value that is +actually stored, including when masking is on? Does the OpenCode Zen free-tier message +rewrite in `src/server/chat-native.ts` alter a paid-tier request? + +**L2 — codex accounts, quota, OAuth.** Can a deferred validation leave an account neither +usable nor visibly failed? Does a revoked pool grant reach a terminal verdict instead of +retrying forever? Does clearing reauth state ever clear it for the wrong account? Does the +new account plan field ever carry a value that identifies the user into a log or the wire? +Does `src/oauth/health.ts` report healthy for an account that cannot actually serve? Does +the quota-header dual-write in `src/codex/quota.ts` ever attribute one account's window to +another? + +**L3 — catalog, providers, combos, config.** Does free-model classification ever mark a +paid model free, or drop a model whose `pricingStatus` is absent rather than `"free"`? +Does quota-exhausted inactive marking recover when quota returns? Does the AI Studio +discovery restoration change behavior for custom gateways? Can a cross-provider blocked +model redirect cycle? Does the keyless free-tier `MissingSessionID` rewrite in +`src/providers/opencode-zen-rate-limit.ts` mask a real auth failure? In `src/config.ts` and +`src/types/config.ts`, what do `privacy.maskEmails` and the inbound body limit resolve to +when the key is absent or malformed — does the schema degrade to a safe default or to +`undefined`? Does the `zcode` config export leak anything it did not before? + +**L4 — management API, service, GUI.** Does any management route lose its auth check? Does +the routed-account label reach a response a browser can read without a session? Does the +launchd bootout recovery ever tear down a healthy job? In `gui/src/pages/models-shared.ts` +and `Models.tsx`, can `freeOnlyInForce` stay true after the control disappears and leave +the user with an empty model list? Does the decode-rate column +(`src/server/management/shared.ts`, `gui/src/pages/Logs.tsx`) stay out of request history +as intended, and is the rate meaningful when the sample is tiny? Do the nine non-English +locales carry the keys this delta actually added — `models.freeOnly`, +`models.inactiveNoCredit`, `logs.detail.decodeTokPerSec`, +`pws.healthLabel.validationPending` — and does any translation invert the meaning of the +English source? Does the account-pool `validationPending` copy tell the operator what to do? + +**L5 — security, privacy, release surface.** Is email masking on by default in the resolved +config, and does the opt-out require an explicit operator action? Read every log call site +added in this delta and name any that can emit an address, token, request body, or account +identifier — static reading only, do not run the scan. Does any `src/lab/` module now reach +`src/router.ts`, `src/server/lifecycle.ts`, or `src/server/responses/core.ts` through any +import chain? Is there any `await` in the synchronous `startServer` activation window in +`src/server/index.ts`, and does the bind-time `maxRequestBodySize` wiring there agree with +the configured limit and its default? Is any gitlink tracked? + +**L6 — operator CLI surface.** Does `ocx status` apply the same masking default as the +library? Does `ocx account refresh` mint or reuse a dashboard session, and does it spend +the user's identity without the code-level gate? Does `--free-only` drop models with an +absent `pricingStatus`? Does `--account` filtering match on a value that is masked in the +stored log? Does the committed `skills/ocx` surface map name any command +`src/cli/capabilities.ts` does not register? + +## What the main session does with the returns + +Nothing is accepted on the lane's authority. Every `BLOCKERS-FOUND` entry is re-derived +against the tree before it enters the wp2 findings table, exactly as the round-1 roadmap +findings were. `040_triage_protocol.md` governs from there. diff --git a/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md b/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md new file mode 100644 index 0000000000..37f429193b --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md @@ -0,0 +1,63 @@ +# Release-readiness decision (wp3) + +wp3 was scoped to triage and remediate release-blocking findings. **The audit produced +none**, so there is nothing to remediate and this cycle is a decision record instead. + +## The decision + +Promote and publish freeze SHA `12c248f52bed88ea13be5b284c79a238feb592d1` as 2.50.0. + +## What the decision rests on + +| Evidence | Detail | +| --- | --- | +| Six independent lanes | All returned `NO-BLOCKER` against the eight clauses, 23-41 files read each, covering all 94 changed product paths | +| Candidate-tree CI | Run `34457689927`, `ci.yml` with `lane=all` on `12c248f52`, attempt 2 conclusion **success** | +| Focused local suites | 84 pass / 0 fail across the web-search bridge, Lab/core boundary, privacy masking, `skills/ocx` surface, body-size limit, live service-manager guard, and context-overflow | +| Main-session re-derivation | Seven invariants re-checked directly rather than accepted from a lane | +| Independent decision audit | Reviewer round 4 returned **GO** and confirmed the release sequence has no defects | +| Freeze tree | `git rev-parse 12c248f52^{tree}` = `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, the value the promotion merge must reproduce | + +## The CI flake, and why it is not being fixed first + +Attempt 1 of run `34457689927` failed one job. One test — +`CodeRabbit Log Guard reclaim regressions > classifies continuous progress stopped by +MAX_ITERATIONS as bounded work` — exceeded the suite-wide `--timeout 60000` after taking +112,853.92 ms on Windows shard 5/6. Everything else passed: 4058 pass, 15 skip, 1 fail. + +Every file on that failure path is byte-identical to the released 2.49.0 tree: + +| File | Blob at `2f3f73629` and at `origin/dev` | +| --- | --- | +| `src/codex/log-guard/maintenance.ts` | `81b3a465b5dbddc11c7431b99fec52012b61cf65` | +| `tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts` | `54e83bba2a62b9fffd39f88839f3c339e1c26080` | +| `tests/helpers/remove-tree.ts` | `53e36a584c627b75a3c3b58a28e2bd17d7636b8b` | + +`tests/preload.ts` is the one file on that path the delta does touch, and the change is +a comment block only — no statement changed. The round-4 reviewer caught that the first +version of this proof enumerated three blobs and called it "every file on the failure +path"; the diff is recorded here so the claim is complete rather than merely true. + +Rerunning the failed job produced attempt 2 with conclusion `success`, which also +demonstrates the mechanic the release gate depends on: `release.yml` searches +`gh run list --workflow ci.yml --commit "$GITHUB_SHA" --event push` and reads the run's +conclusion, and a rerun updates that conclusion in place. + +Hardening the timeout would move the freeze SHA, void this audit, and reopen every gate +for a test that 2.49.0 already shipped with the same bytes and the same limit. The +mitigation is the rerun, applied again on the promotion merge if it recurs. + +## Recorded limits of the audit + +The round-4 reviewer named three, and they are recorded rather than argued away. + +1. **Every lane was a static reader.** `NO-BLOCKER` means no clause matched a read, not + that the new SSE bridge cannot hang at runtime. The 84 focused tests and the full + `lane=all` CI run are what cover the dynamic half; the lane verdicts alone are not. +2. **The re-derivation table checks invariants, not the packet questions.** It confirms + masking, the body limit, the Lab boundary, `startServer`, gitlinks, i18n keys, and the + bridge opt-in. It does not independently re-answer the inbound `developer` remap, the + 90-second WS prelude, or `freeOnlyInForce`; those rest on the lane read plus CI. +3. **Treating an unresolved `RUNTIME-CHECK` as a `BLOCK` creates pressure to under-report + it.** Exactly one finding carried that label and it was resolved by tracing consumers. + A lane that quietly downgrades rather than raising the label would not be visible here. diff --git a/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md b/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md new file mode 100644 index 0000000000..d5c03912cb --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md @@ -0,0 +1,103 @@ +# 2.50.0 execution runbook (wp4) + +The exact sequence, with the value each step must record. `020_release_plan.md` says why; +this says what to run. Every SHA below is written down as it is produced, because the next +step verifies against it rather than against "current". + +## Fixed inputs + +| Name | Value | +| --- | --- | +| Freeze SHA | `12c248f52bed88ea13be5b284c79a238feb592d1` | +| Freeze tree | `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| Version | `2.50.0` | +| Previous release | `v2.49.0` at `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | +| Default branch | `main` | + +## Step 1 — pre-move `dev` + +```sh +gh workflow run dev-version-bump.yml --ref main \ + -f intended-version=2.50.0 -f mode=pre-move +``` + +The workflow opens a pull request; it cannot push to `dev` because the `Protect dev` +ruleset requires review. Merge that PR, then confirm: + +```sh +git fetch origin dev +git show origin/dev:package.json | head -3 # must read 2.51.0 +``` + +Record: the bump PR number and the merged `dev` SHA. + +Why this is first: `release.yml:242-249` runs +`bun scripts/version-line.ts assert-ahead 2.50.0`, which fails while `dev` +is still 2.50.0. Doing it after the promotion would strand a published-but-refused release. + +## Step 2 — promote the freeze SHA to `main` + +```sh +git fetch origin main +git switch -c codex/release-250-main origin/main +git merge --no-ff 12c248f52 -m "release: promote verified 2.50.0 product tree to main" +git rev-parse HEAD^{tree} # must equal d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822 +``` + +If the tree does not match, a conflict resolution changed the product and the audit no +longer describes what would ship. Stop and re-derive rather than adjusting the expectation. + +Open the PR into `main` using `.github/PULL_REQUEST_TEMPLATE.md`, merge it, then: + +```sh +git fetch origin main +git rev-parse origin/main # record as MERGE_SHA +git rev-parse origin/main^{tree} # must still equal the freeze tree +``` + +Record: the promotion PR number, `MERGE_SHA`, and the confirmed tree. + +## Step 3 — wait for the release-branch gates on `MERGE_SHA` + +Both fire automatically on the merge push — `ci.yml` because `main` is in its push +branches and `src/**`/`gui/**` changed, `service-lifecycle.yml` because `package.json` and +`src/cli/index.ts` are in its push paths. + +```sh +gh run list --workflow ci.yml --commit "$MERGE_SHA" --event push --json conclusion,url +gh run list --workflow service-lifecycle.yml --commit "$MERGE_SHA" --json conclusion,url +``` + +Both must reach `success`. If Windows shard 5/6 times out on the Log Guard reclaim test +again, rerun that job in place with `gh run rerun --failed`; the gate reads the run's +conclusion, which a rerun updates. That is the recorded mitigation, not an improvisation. + +## Step 4 — dry run, then publish + +```sh +gh workflow run release.yml --ref main \ + -f version=2.50.0 -f tag=latest -f expected-sha="$MERGE_SHA" -f dry-run=true +``` + +A dry run still executes `prepublishOnly` (typecheck plus the GUI build), so a green dry +run is real evidence about the package, not a formality. Only then: + +```sh +gh workflow run release.yml --ref main \ + -f version=2.50.0 -f tag=latest -f expected-sha="$MERGE_SHA" -f dry-run=false +``` + +## Step 5 — verify the artifacts independently + +```sh +npm view @bitkyc08/opencodex dist-tags --json +npm view @bitkyc08/opencodex@2.50.0 version gitHead dist.integrity --json +git ls-remote --tags origin | grep v2.50.0 +gh release view v2.50.0 --json tagName,isDraft,isPrerelease,createdAt +``` + +`gitHead` must equal `MERGE_SHA`. npm propagation lag shows a 404 or a stale `latest` +for a while; poll. **Never republish because a smoke step timed out** — inspect metadata, +provenance, and the tarball first, because npm may already have accepted the publish. + +Record every value into the release-artifacts table in `030_evidence.md`. diff --git a/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md b/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md new file mode 100644 index 0000000000..43f3996610 --- /dev/null +++ b/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md @@ -0,0 +1,42 @@ +# Delivery record — 2.50.0 + +Published 2026-09-10. `@bitkyc08/opencodex@2.50.0` is the npm `latest`. + +## The chain, end to end + +| # | What | Value | +| --- | --- | --- | +| 1 | Released baseline | `v2.49.0`, `main` `2f3f736299dca38861f8fb9c4326a4b4d7c664bc` | +| 2 | Audited freeze SHA | `12c248f52bed88ea13be5b284c79a238feb592d1`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| 3 | Candidate CI | `ci.yml` `lane=all` run 34457689927, success | +| 4 | `dev` pre-move | run 34463313646 -> PR #4194 -> `dev` `cf44f6fe887d19f53ede1e09abfe0fe3cf137059` at 2.51.0 | +| 5 | Promotion commit | `3a3de889b6ef3217497f6c5029acf08aec09c0cf` | +| 6 | `main` merge SHA | PR #4195 -> `2d4d7a22381a2e497c2442902104619e25f937c7`, tree `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822` | +| 7 | Release-branch gates | `ci.yml` 34464454730 success, `service-lifecycle.yml` 34464454609 success | +| 8 | Dry run | `release.yml` 34465317829 success | +| 9 | Publish | `release.yml` 34465442114 success | +| 10 | Registry | `latest` = 2.50.0, `gitHead` = `2d4d7a223`, tarball sha512 matches `dist.integrity`, SLSA v1 provenance present | +| 11 | Tag and release | `v2.50.0` -> `2d4d7a223`, GitHub release published, not a draft | + +**One tree throughout.** The audited freeze tree, the promotion commit's tree, and the +merged `main` tree are the same object, `d8f5a7143bcd6cb86185c4e8d4c6a6c4ad0fa822`, and +three audited source files inside the published tarball hash identically to that tree. What +shipped is what was read. + +## What the audit cost and produced + +Seven `xai/grok-4.6` subagent runs: one standing reviewer across four rounds, and six +concurrent audit lanes. The reviewer failed the first roadmap outright, and that was the +most valuable moment in the whole unit — it caught that the release order did not match +what `release.yml` gates on, that promoting the 2.50.0 tree onto `preview` would have +broken that branch's version line, and that four `src/cli` files had no lane. The lanes +then returned no blockers, and the fourth round audited the release decision rather than +the code and returned GO. + +## What is deliberately unfinished + +- `preview` stays at `2.49.0-preview.20260909`, npm `preview` at `2.48.0-preview.20260908`. + It needs its own `2.50.0-preview.` commit, which is a separate decision. +- Seven non-blocking findings (F1-F7) are recorded but not filed as issues. +- The Windows Log Guard reclaim test remains able to exceed the 60s suite limit on a slow + runner. It is unchanged since 2.49.0; the mitigation is a job rerun, exercised twice here. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md new file mode 100644 index 0000000000..179bdb55d4 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md @@ -0,0 +1,225 @@ +# Unconditional `max`/`ultra`, and the stale clamp that hides them + +## Reader summary + +Two catalog defects share one shape: OpenCodex decides what the Codex client may +render, and in both cases it decides "nothing" for the wrong reason. First, a +persisted effort-clamp diagnostic keeps `max` and `ultra` hidden after the Codex +binary is upgraded in place, because the diagnostic is matched to the current +runtime by **path alone** — the recorded version is never compared when the path +is unchanged, which is the normal Windows auto-update case. Second, the two +presentation fields the Codex client renders as cards (`availability_nux`, +`upgrade`) survive end to end on pin-backed native rows but are discarded where +the account roster is parsed (`model-entitlements.ts` keeps only the slug), so a +card whose copy lives only on the account endpoint can never appear through the +proxy. (An earlier draft of this paragraph said "deleted at four sites and +carried at none"; the explorer pass disproved it — see +`020_architecture_dispositions.md`.) This unit makes the clamp +diagnostic version-aware, exempts `max` and `ultra` from the observed-runtime +intersection entirely, and — pending a probe — lets the account roster contribute +the presentation copy it currently discards. The per-consumer ladder projection in +the first draft was withdrawn by maintainer ruling; the delete-site registry was +withdrawn because the explorer pass showed native rows already keep the fields. + +Evidence: `010_evidence.md`. Upstream issues: #4204 (efforts), #4213 (cards). + +## Loop spec + +| Field | Content | +| --- | --- | +| Loop archetype | Satisfy-spec. Both defects have a decidable correct behaviour; this is not an open-ended optimization. | +| Trigger | User request: make `max`/`ultra` reachable regardless of the CLI version recorded on disk, and make the card fields usable as boilerplate for other presentation surfaces. | +| Goal | A Codex client that supports an effort sees it offered; a presentation field that upstream populates survives to the client; adding the next such field is a registry entry, not a new code path. | +| Non-goals | Proxying `wham/workspace-messages`. Changing image generation. Authoring card copy that upstream does not ship. Pushing to any remote. Clamping rungs other than `max`/`ultra` — the rest of the ladder keeps its current observed-runtime behaviour. | +| Verifier | `bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` — RUN, exit 0, 42 pass, and it reads `src/codex/runtime.ts` (`codex-runtime.test.ts:37` imports it; `:612` calls `effortClampAppliesToRuntime`). **That pair is sufficient for Phase 1 and NOT sufficient for Phase 2** — `catalog-go-exact-efforts.test.ts` pins construction, never the clamp. The gate that observes Phase 2 is `bun test tests/codex-integration/codex-catalog.test.ts tests/codex-integration/reserve-catalog.test.ts tests/clients/client-catalog-compatibility.test.ts` (see `030_test_impact.md` for the exact test names that must invert). NOT RUN at plan time — inverting them is B's work, so a pass today would prove nothing. Phase 3 has no gate until its test exists; that acceptance row is human review. | +| Stop condition | ~~Plan-only~~ Superseded 2026-09-11 (second session): the user authorised the full PABCD cycle. This cycle ends at D with Phases 1, 2 and 4 built and verified. Phase 3 stays withdrawn (GAP-2). | +| Memory artifact | This unit directory: `devlog/_plan/260911_catalog_presentation_and_effort_projection/`. | +| Expected terminal outcomes | Success: Phases 1, 2 and 4 land on `codex/260911-clamp-expiry` with their gates green. Resolved: GAP-1 (CLAMP-04 ships — see `050_revalidation.md`). Unresolved: GAP-2/GAP-3 in `040_open_gaps.md` are not closed by this cycle. Blocked: Phase 3 stays blocked on a live account-roster probe that only the user can authorise. | +| Escalation condition | The account-roster probe needs a real ChatGPT token; main does not take it. CLAMP-04 changes the meaning of an active clamp and inverts diagnostic tests the plan currently promises to keep green — that contradiction goes to the user, not to B. Reserve deletion is no longer an escalation: CLAMP-05 decided it. | + +## Phase map + +Ordered by build dependency. Each phase closes with something independently +verifiable. + +### Phase 1 — Foundation: the clamp diagnostic must expire + +`effortClampAppliesToRuntime` (`src/codex/runtime.ts:431`) returns `true` as soon as +the recorded path equals the resolved runtime path, before it ever looks at the +version. An in-place upgrade therefore keeps a clamp alive forever. This machine +is in exactly that state: the diagnostic records `0.135.0` for a path that now +reports `0.154.0`, and that binary's own bundled catalog contains `max` and +`ultra`. + +Change: when the diagnostic and the runtime both carry a version and those +versions differ, the diagnostic does not apply — regardless of path equality. A +missing version on either side stays conservative and keeps the current +path-match behaviour, because an unknown version is not evidence of an upgrade. + +This narrows nothing and weakens no clamp: it invalidates an observation that is +provably about a different binary. It is also the phase the other two depend on, +because a stale diagnostic would mask whatever Phase 2 computes. + +Accept criteria: + +- Given a diagnostic at version A and a runtime at the same path at version B (A ≠ B), `effortClampAppliesToRuntime` returns `false`. +- Given the same path and the same version, it still returns `true`. +- Given a diagnostic with a null version, current behaviour is unchanged. +- Activation scenario for the guard: construct the two-version case in the test and assert `ocx status` composes without the clamp warning — the observable effect is the absent `Catalog clamp removed` line at `src/cli/status.ts:277`. + +Files: `src/codex/runtime.ts` (`effortClampAppliesToRuntime`), `tests/codex-integration/codex-runtime.test.ts` (extend near line 612). + +### Phase 2 — Core: `max` and `ultra` stop being clampable + +**Maintainer ruling, 2026-09-11.** Emit `max` and `ultra` unconditionally. The +consumer-projection design in the first draft of this plan is withdrawn, and the +#4204 review constraint it was written against is superseded by the person who +wrote it. Rationale on the record: enough time has passed that the CLI versions +which genuinely lack the two rungs are effectively unsupported, so a clamp that +exists to protect them costs more than it buys. + +What the clamp does today: the ladder comes from `codex debug models --bundled` +of the resolved runtime (`src/codex/catalog/effort.ts:331` → +`src/codex/catalog/bundled.ts:239`), alternative-runtime discovery is off for that +call (`bundled.ts:261` defaults `discoverAlternatives` to `false`; +`runtime.ts:603` breaks out of the candidate loop when it is `false`), so the +persisted binary decides the ladder for the whole machine. + +Mechanism, per CLAMP-01/03 in `020_architecture_dispositions.md` and the A-audit +correction (reviewer blocker 1, folded): the single predicate site is the +keep-filter inside `clampEntryToCodexSupportedEfforts` — `effort.ts:357`, where +`kept` is built with `supported.has(...)`. A rung survives when it is in +`supported` **or** it is `max`/`ultra`. The same predicate gates BOTH default-repair +blocks — the Reserve branch's own repair at `effort.ts:363-367` (which returns +before the shared block) and the shared block at `effort.ts:378`. One predicate, +three places. No new module, no signature change at `sync.ts:1945` or +`convergence.ts:382`, no `bundled.ts` discovery change, no consumer binding. + +Explicitly rejected: re-adding the rungs after the clamp via +`ensureUltraReasoningLevel` (`effort.ts:300`). It no-ops on an empty ladder, and it +would leave `removedEfforts` naming rungs that were put back — a diagnostic that +lies. Also rejected: a floor allowlist, which would strip `none`/`minimal` that +current CLIs do parse. + +**Emission and admission stay separate (CLAMP-02).** +`supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`) keeps +reporting what it observes, and `catalogEffortCompatibility` (`effort.ts:409`, +`src/client/catalog-compatibility.ts:47`) keeps refusing a hub catalog an old +runtime cannot parse. Making observation lie would reintroduce #4207: a hub client +on a leftover 0.135 CLI would write the file and then crash reading it. + +**Reserve (CLAMP-05).** `requiresExactReserveEfforts` (`effort.ts:344`) deletes a +row whose ladder empties (the `omitted`/`splice` at `effort.ts:466,472` are pure +effects of the emptied ladder — they get NO special case). The keep falls out of +the `:357` filter: when `max`/`ultra` are the sole survivors `kept` is non-empty, +so the row is kept with exactly those rungs. `{xhigh}` vs `{medium}` still +deletes; `{low,high}` vs `{medium,high}` still yields `{high}`. + +Phase 1 is not made redundant by this: the diagnostic still exists for other rungs, +and a same-version leftover listing only `max`/`ultra` would keep warning without +the CLAMP-04 filter. + +**Landing constraint (A-audit blocker 2, folded).** Phases 1 and 4 must not land +without Phase 2 in the same diff: `liveRemovedEfforts` already hides rungs that +`clampEntryToCodexSupportedEfforts` still removes, so landing 1+4 alone makes +`ocx status`/`ocx doctor` report "no clamp" while the next sync still strips the +rungs. One branch, one landing. + +Accept criteria: + +- With a fixture runtime whose bundled catalog stops at `xhigh`, a native row ends the sync carrying `max` and `ultra`. +- A genuinely absent rung that is NOT `max`/`ultra` is still removed — asserted explicitly, so this is provably an exemption and not a disabled clamp. +- `default_reasoning_level: "ultra"` is no longer rewritten to `xhigh` when the ladder kept `ultra` (`effort.ts:378`). +- `catalogEffortCompatibility` still reports `unsupportedEfforts: ["max"]` against an old-CLI ladder — the #4207 gate is unchanged. +- Activation scenario for the reserve branch: a reserve fixture whose source ladder is `max`/`ultra`-only against an observed `{medium}`; the observable effect is that the row appears in the written catalog instead of being spliced out, while the existing `{xhigh}` vs `{medium}` fixture still produces an omitted row. + +Files: `src/codex/catalog/effort.ts` (only). Unchanged by design: `sync.ts:1945`, `convergence.ts:382`, `bundled.ts`, `src/client/catalog-compatibility.ts`. + +### Phase 3 — Integration: keep the account roster's presentation fields + +**The first draft had this backwards.** The four `delete` sites are not why no card +appears — a pin-backed native row already carries both fields end to end, and +`tests/codex-integration/codex-catalog.test.ts:7398` pins exactly that. The carrier +exists. Full derivation in `020_architecture_dispositions.md`. + +The real loss is upstream. `src/codex/model-entitlements.ts` fetches +`https://chatgpt.com/backend-api/codex/models`, and `parseAccountModels` (`:536-546`) +keeps **only the slug** — `supported_in_api` and `visibility` are read as filters and +every other field, presentation included, is dropped on the floor. The set is then +used as an allowlist for account-gated natives (currently just Daybreak). So Astra's +row is always the pin or the bundled catalog, and both carry +`availability_nux: null`. + +Change: let the account roster contribute presentation fields for a native slug it +already authorises, through one small descriptor that names which fields may cross +that boundary and in which direction the account roster wins over the pin. That +descriptor is the reusable piece the user asked for — the next presentation field +becomes an entry rather than a new merge path. + +**Blocked on evidence, by design.** Whether the account roster carries copy the pin +lacks is unverified and needs a live ChatGPT token. If it does not, Phase 3 is +withdrawn rather than built — there would be nothing to carry, and a descriptor +with no producer is the ghost state `PLAN-FIELD-CHAIN-01` exists to prevent. + +PLAN-FIELD-CHAIN-01 for the descriptor: + +| Stage | Path | +| --- | --- | +| Creation | account roster response parsed at `src/codex/model-entitlements.ts:536`; today the only producer, and it currently produces nothing | +| Serialization | none — both fields already exist in the catalog JSON written by `sync.ts`; no new wire shape | +| Deserialization | `src/codex/catalog/parsing.ts` entry normalization; `ensureStrictCatalogFields` already routes by `isRouted` | +| Consumers | the merge in `sync.ts` (`finishUpstreamNativeEntry`, `:257`), plus the four existing sanitizers which stay as they are — `metadata.ts:567` (Daybreak capability alias), `sync.ts:354` (template clone), `parsing.ts:613` (routed), `reserve.ts:36` (Reserve). **N/A by design:** none of them gains a registry lookup, because each is already correct. | + +Accept criteria: + +- A native slug whose account-roster row carries `availability_nux` ends the sync carrying it, overriding a `null` pin. +- A routed row, the Daybreak capability alias, and a Reserve projection still lose the field — asserted per row kind, not once, so the fix is proved not to have widened. +- `parseAccountModels`' existing filtering (`supported_in_api !== true`, `visibility === "hide"`) is unchanged; a hidden row contributes no copy. +- Activation scenario: a fixture roster carrying copy for one native slug and nothing for another; the observable effect is one row with a message and one still `null` in the written catalog. + +Files: `src/codex/model-entitlements.ts`, `src/codex/catalog/sync.ts`, new test under `tests/codex-integration/` (needs an entry in both `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`, or a name matching the `codex-integration` regex seed). + +### Phase 4 — Hardening: say what happened + +`ocx doctor` currently suggests "set CODEX_CLI_PATH to a newer Codex binary" while +the selected binary is already newer — the advice is generated from the stale +diagnostic. Once Phase 1 lands, the message must distinguish "this runtime really +lacks the rung" from "a previous runtime lacked it". `doctor.ts:1180` does not call +`effortClampAppliesToRuntime` at all — it warns on any non-empty `removedEfforts` — +so status and doctor can disagree about the same file. Accept criterion: given one +leftover diagnostic, `ocx status` and `ocx doctor` reach the same verdict. Docs-site +follows only if Phase 2 changes user-visible behaviour, which it does: `max` and +`ultra` now appear on runtimes that previously hid them. + +Files: `src/cli/doctor.ts:1181`, `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`, `docs-site/`. + +## Scope boundary + +IN: `src/codex/catalog/effort.ts` (Phase 2), `src/codex/runtime.ts` (Phases 1 and 4), `src/cli/{status,doctor}.ts` and `src/server/management/config-routes.ts` (Phase 4), and — only if the Phase 3 probe succeeds — `src/codex/model-entitlements.ts` and `src/codex/catalog/sync.ts`. Matching tests, docs-site. + +OUT, and explicitly unchanged by design: `src/codex/catalog/{bundled,parsing,metadata,reserve}.ts`, `src/client/catalog-compatibility.ts` and `catalogEffortCompatibility` (CLAMP-02), `supportedCodexReasoningEffortsFromObservedCatalog`, `nativeEffortClamp` and the wire-clamp layer, `src/server/index.ts` route allowlist, `src/server/images.ts`, `src/lab/`, the `wham` client surface, and hand-authored `upstream-models.json` copy. There is no new presentation-field module; that idea was withdrawn. + +## PLAN-BYPASS-NAMED-01 + +The thing being enforced is the CLAMP-02 boundary: emission may exempt the two +rungs, hub admission may not. + +- Tier: E2 — repository tests. +- Executing surface: `tests/clients/client-catalog-compatibility.test.ts` plus `bun run test` in CI. +- Known bypass path: a contributor who adds the exemption to `supportedCodexReasoningEffortsFromObservedCatalog` instead of to `clampEntryToCodexSupportedEfforts` gets the same visible outcome locally and silently reopens #4207. The compatibility tests would catch that specific move; a new code path that recomputes the supported set elsewhere would not be caught at all. +- Residual risk: a local `ocx sync` on a leftover pre-0.14x CLI can now write a catalog that CLI cannot parse. Accepted by the ruling; hub clients still fail closed. +- Wording: **early warning**, not enforcement. Final layer: none. + +## Consultation record + +Architect consultation completed on `xai/grok-4.6` through the connected hub: +proposal (CLAMP-01..06) in `020_architecture_dispositions.md`, main dispositions in +the same file, reflection check returned **MISALIGNED** with five findings. +Findings 1 and 4 — the document contradicting its own loop-spec and scope — are +resolved in this revision. Findings 2, 3 and 5 are recorded unresolved in +`040_open_gaps.md`. Two explorers ran alongside on disjoint questions; their output +is `030_test_impact.md` and the Phase 3 correction in `020`. + +This plan has **not** passed an independent A audit. The reflection is the +architect checking its own proposal against main's rewrite; it does not substitute +for A. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md new file mode 100644 index 0000000000..b25bf214d4 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md @@ -0,0 +1,134 @@ +# Evidence + +Collected 2026-09-11 on the reporting Windows machine. Read-only except for +`bun install`, which populated `node_modules` so the verifier could run. + +## 1. The clamp diagnostic outlives the binary it describes + +`~/.opencodex/codex-runtime-clamp.json`: + +```json +{ + "version": 1, + "updatedAt": "2026-09-10T12:01:09.525Z", + "runtimePath": "C:\\Users\\\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe", + "runtimeVersion": "0.135.0", + "removedEfforts": ["max", "ultra"], + "affectedModels": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", + "anthropic/claude-fable-5-1", "anthropic/claude-opus-4-6", "anthropic/claude-opus-5"] +} +``` + +The binary at that exact path now reports `codex-cli 0.154.0`, and its own +bundled catalog carries both rungs: + +``` +$ codex debug models --bundled +... "slug":"gpt-6-astra" ... "supported_reasoning_levels":[ + {"effort":"low"...},{"effort":"medium"...},{"effort":"high"...}, + {"effort":"xhigh"...},{"effort":"max"...},{"effort":"ultra"...}] +``` + +`ocx status` nevertheless reports: + +``` +Codex version: 0.154.0 +Catalog clamp: active +Removed efforts: max, ultra +``` + +and `ocx doctor`: + +``` +ok Selected runtime: ...\codex.exe (0.154.0, source=configured) +!! max and ultra were removed during catalog sync. + Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync. +``` + +The advice is impossible to follow — the selected binary is already the newer one. + +## 2. Why: path equality short-circuits the version check + +`src/codex/runtime.ts:431` + +```ts +export function effortClampAppliesToRuntime(diagnostic, runtime): boolean { + if (!diagnostic || diagnostic.removedEfforts.length === 0) return false; + if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true; // <-- returns before version is read + return Boolean(diagnostic.runtimeVersion && runtime.version + && diagnostic.runtimeVersion === runtime.version); +} +``` + +The version comparison on the last line is only reachable when the paths +**differ**. An in-place upgrade — which is how the Windows Codex install updates — +keeps the path identical, so the stale diagnostic is treated as current forever. +Consumers: `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`. + +## 3. Where the effort ladder is derived + +- `src/codex/catalog/effort.ts:331` `codexSupportedReasoningEfforts` → `loadBundledCodexCatalog` +- `src/codex/catalog/bundled.ts:225` runs `debug models --bundled` +- `src/codex/catalog/bundled.ts:261` passes `discoverAlternatives: deps.discoverAlternatives ?? false` +- `src/codex/runtime.ts:603` `if (deps.discoverAlternatives === false) break;` — candidate search stops after the persisted entry +- `src/codex/runtime.ts:582` the persisted command is pushed first with source `configured` +- Applied at `src/codex/catalog/sync.ts:1945` and `src/codex/convergence.ts:382` + +So the machine-wide ladder is whatever the persisted binary reports, with no +notion of which client will render it. + +## 4. Card fields: deleted everywhere, carried nowhere — OPEN QUESTION + +Delete sites: + +| Path | Row kind | Fields | +| --- | --- | --- | +| `src/codex/catalog/metadata.ts:567` | alias | `availability_nux` | +| `src/codex/catalog/sync.ts:354` | routed | `upgrade = null`, `availability_nux` | +| `src/codex/catalog/parsing.ts:613` | routed | `availability_nux`, `upgrade` | +| `src/codex/catalog/reserve.ts:36` | reserve projection | `availability_nux` | + +Client side, for reference (openai/codex at submodule HEAD): + +- `codex-rs/protocol/src/openai_models.rs:409,410` — `ModelInfo.availability_nux`, `ModelInfo.upgrade` +- `codex-rs/tui/src/app/startup_prompts.rs:203,211` — NUX selection and a four-show cap +- `codex-rs/app-server/src/models.rs:31` — `upgrade` / `upgrade_info` forwarded to the desktop app + +**Unresolved.** `src/codex/data/upstream-models.json:928` has `availability_nux: null` +for `gpt-6-astra`, and so does the live `debug models --bundled` output above. Only +`gpt-5.6-sol` and `gpt-5.5` carry copy in the bundled catalog. That means the +bundled catalog may simply not be where Astra's announcement lives — the account +endpoint `backend-api/codex/models?client_version=...` is the other candidate, and +it has not been probed. **Phase 3 builds a carrier; whether there is anything to +carry for Astra is not yet established.** Probing it needs a live ChatGPT account +token and is a user decision, not an agent one. + +Separately, the `workspace-messages` channel (`headline` / `announcement`, +`codex-rs/backend-client/src/client.rs:651`) has zero references in this +repository. It is out of scope here and is gated client-side on +`auth.uses_codex_backend()` (`account_processor.rs:1307`), which is false for +`AuthMode::ApiKey` (`codex-rs/protocol/src/auth.rs:61`) — the mode produced by +`env_key` injection at `src/codex/inject.ts:327`. + +## 5. Verifier, actually run + +``` +$ bun install +103 packages installed + +$ bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts +42 pass, 0 fail, 160 expect() calls # exit 0 +``` + +Reads the change target: `tests/codex-integration/codex-runtime.test.ts:37` imports +`../../src/codex/runtime`; line 612 calls `effortClampAppliesToRuntime` directly. + +A first attempt failed with `Cannot find module 'zod/v4'` before `bun install` — +recorded because "the verifier passed" would otherwise be unverifiable. + +## 6. Not established + +- Whether Astra carries announcement copy on the account catalog endpoint (§4). +- Which consumer should own the shared catalog when Desktop and CLI disagree. +- Whether any non-Windows install reproduces §1; the in-place-upgrade shape was only observed here. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md new file mode 100644 index 0000000000..3c82aacaa0 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md @@ -0,0 +1,49 @@ +# Architecture consultation and main dispositions + +Architect: `xai/grok-4.6` via the connected hub, read-only, dispatched 2026-09-11. +Two explorers ran alongside it on disjoint questions (test impact; presentation-field +flow). Verdict on the first draft of `000_plan.md`: **MISALIGNED**. Main accepts most +of it; the dispositions below are what actually governs B. + +## Decisions + +| ID | Proposal | Main disposition | +| --- | --- | --- | +| CLAMP-01 | Keep the clamp; a rung survives if it is in `supported` **or** it is `max`/`ultra`. Apply the same predicate to the default-repair block (`effort.ts:378`), which today would still rewrite `default_reasoning_level: "ultra"` even on a ladder that kept `ultra`. Do not post-patch with `ensureUltraReasoningLevel` — it is a construction helper, no-ops on empty ladders, and would make `removedEfforts` report rungs that were put back. | **Accepted.** This is the mechanism. The default-repair catch is a real defect the plan missed. | +| CLAMP-02 | Do **not** union `max`/`ultra` into `supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`), and do not touch `catalogEffortCompatibility` (`effort.ts:409`, `src/client/catalog-compatibility.ts:47`). Emission and hub admission are different questions; hub download stays fail-closed per #4207. | **Accepted, and promoted to a scope boundary.** A hub client on a leftover 0.135 CLI must keep refusing rather than writing a catalog it cannot parse. | +| CLAMP-03 | One predicate change in `clampEntryToCodexSupportedEfforts` (`effort.ts:348`). No new module, no signature change at `sync.ts:1945` or `convergence.ts:382`, no consumer binding in `runtime.ts`, no `bundled.ts` discovery change. | **Accepted.** Strictly smaller than the plan's Phase 2. | +| CLAMP-04 | Keep `codex-runtime-clamp.json`. After CLAMP-01 a sync whose only removals were `max`/`ultra` persists `null` and unlinks. Until that sync, filter the two rungs out of "active clamp" in a single helper that **both** `effortClampAppliesToRuntime` and `doctor.ts` call — today `ocx doctor` (`doctor.ts:1180`) never calls the helper `ocx status` uses (`status.ts:249`), so the two can disagree about the same file. | **Accepted.** This explains the observed contradiction in `010_evidence.md` §1 and is a second, independent defect. Phase 1 stays: it is necessary for non-`max`/`ultra` rungs and insufficient alone, because a same-version leftover listing only those two would still warn. | +| CLAMP-05 | Keep reserve exactness. `{xhigh}` vs `{medium}` still deletes the row; `{low,high}` vs `{medium,high}` still yields `{high}`. Only the case where `max`/`ultra` are the sole survivors changes: the row is **kept** instead of spliced out (`effort.ts:466,472`). | **Accepted.** This closes the escalation the plan left open. Add the focused reserve test; do not weaken the existing xhigh-vs-medium omission test. | +| CLAMP-06 | Drop the per-consumer projection. One shared `$CODEX_HOME/opencodex-catalog.json`, no consumer key in the diagnostic, no second catalog. Desktop/CLI disagreement is resolved by always offering the two rungs. | **Accepted.** The "who owns the shared file" blocker in the first draft is obsolete. | + +Residual risk accepted with CLAMP-06: a leftover pre-0.14x CLI reading the shared +file locally can fail to parse it. Hub clients still fail closed. Local `ocx sync` +does not, and that is the stated cost of the ruling. + +## Correction to Phase 3 — the premise was wrong + +The first draft assumed the four `delete` sites were why no card appears. The +explorer pass disproves it. **Pin-backed native rows already keep both fields end +to end** — `upstreamNativeEntry` deletes only `minimal_client_version`, +`finishUpstreamNativeEntry` (`sync.ts:257`) does not touch them, and +`ensureStrictCatalogFields` strips them only when `isRouted === true` +(`parsing.ts:609`). There is a test pinning exactly this: +`tests/codex-integration/codex-catalog.test.ts:7398` — "a native row keeps its own +eligibility metadata" — and `:3529` asserts Sol's `availability_nux` is defined. + +So the carrier exists. Three native-looking kinds still lose the field, each for a +defensible reason: the Daybreak capability alias (`metadata.ts:567`), older natives +not in `UPSTREAM_NATIVE_ENTRIES` when OpenCodex has to synthesize the row +(`deriveEntry`), and Reserve. + +**The actual gap is upstream of all four sites.** `src/codex/model-entitlements.ts` +does fetch `https://chatgpt.com/backend-api/codex/models`, but `parseAccountModels` +(`:536-546`) keeps nothing except the slug — every presentation field in that +response is discarded, and the set is used only as an allowlist for account-gated +natives (currently just Daybreak). Astra's row therefore comes from the pin or the +bundled catalog, both of which carry `availability_nux: null`. + +That reframes the open question in `010_evidence.md` §4. It is no longer "does the +carrier exist" but "does the account roster carry copy the pin does not, and should +it override the pin". The probe still needs a live account token and is still a user +decision. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md new file mode 100644 index 0000000000..c476f80df3 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md @@ -0,0 +1,57 @@ +# Test impact of the `max`/`ultra` exemption + +From the explorer pass (`xai/grok-4.6`, read-only). This is the list B inverts, and +it is the reason the loop-spec verifier row now names three files the first draft +did not. + +## Must invert — these encode the behaviour being removed + +`tests/codex-integration/codex-catalog.test.ts`, `describe("Codex reasoning-effort capability clamp")` at `:7027`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| the observed-state clamp is pure with respect to frozen runtime evidence | 7051 | expects `removedEfforts: ["max","ultra"]` and a default rewritten to `xhigh` | +| strips max and ultra when the installed Codex ladder stops at xhigh | 7077 | the exemption is precisely this case | +| falls back to the conservative universal ladder when every advertised effort is unsupported | 7095 | a `max`/`ultra`-only row must no longer collapse to `low/medium/high` | +| repairs an unsupported max default to the highest surviving xhigh rung | 7107 | `effort.ts:378`, the default-repair block CLAMP-01 also changes | + +`tests/codex-integration/codex-runtime.test.ts`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| clamp diagnostics include unsupported default_reasoning_level changes | 1005 (listed as 940 before the new Phase 1 tests shifted it) | runs the live clamp and expects `ultra` → `high` with `"ultra"` in `removedEfforts` | + +## Must keep passing — assert these explicitly, they are the proof it is an exemption + +- `preserves max and ultra when the installed Codex ladder includes them` — `codex-catalog.test.ts:7086` +- `is a no-op when the installed Codex binary cannot be probed` — `codex-catalog.test.ts:7115` +- `final clamp omits incompatible Reserve in-place without inventing efforts` — `reserve-catalog.test.ts:239`; `{xhigh}` vs `{medium}` still deletes the row. Fails only if the whole clamp is disabled, which is the mistake this plan is trying not to make. +- `partial effort intersection keeps only source efforts and a surviving default` — `reserve-catalog.test.ts:252` +- The four `#4207` cases in `tests/clients/client-catalog-compatibility.test.ts:37,51,76,97` — they do not mutate the catalog, and they fail **only** if CLAMP-02 is violated by also treating the two rungs as always compatible. They are the regression gate for the hub boundary. +- Runtime tests that seed `persistEffortClamp` themselves and therefore do not depend on live stripping: `codex-runtime.test.ts:601, 624, 815, 842` — line numbers verified stale by the A reviewer (the Phase 1 test insertions shifted them; the tests are found by name, not line). + +## Out of scope — a different layer, do not touch + +The wire clamp (`nativeEffortClamp`, `effort.ts:52`, consumed at +`src/server/responses/core.ts:2582`) still maps `max`/`ultra` down for natives that +only mock those rungs. Catalog advertisement and wire honesty are deliberately +split, as `structure/03_catalog-and-subagents.md:339` already records. Affected +suites that must stay green unchanged: `codex-v2-gate.test.ts:1821`, +`effort-policy.test.ts:434`, `reasoning-effort.test.ts:887`, +`openai-responses-passthrough.test.ts:559`, `claude-model-info.test.ts:63`, +`vision-reasoning-contract.test.ts:193`. + +Likewise the construction-side exactness suites — `catalog-go-exact-efforts.test.ts`, +`codex-v2-gate.test.ts:111-126`, the none-only and combo ladder pins in +`codex-catalog.test.ts` — fail only if "unconditional" is misread as "always **add** +`max`/`ultra`". It is not: Go rows, Luna, combo rows, and none-only custom ladders +keep their exact ladders. Anything that grows Muse to include `max` or Luna to +include `ultra` is a defect, not the feature. + +## New test file placement + +`tests/test-layout.test.ts:20` forbids a root-level file that resolves to a migrated +domain. A new file needs matching entries in `scripts/test-layout/layout.json` +`explicit` and `tests/fixtures/test-layout-expected.json`; the `codex-integration` +regex seed already matches an `effort-*.test.ts` name until those exist +(`layout.json:34`). diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md new file mode 100644 index 0000000000..783687138d --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md @@ -0,0 +1,69 @@ +# Unresolved after the reflection check + +The architect reflection returned MISALIGNED with five findings. Two were document +coherence and are fixed in `000_plan.md`. These three are real and are **not** +closed. B does not start Phase 3, and does not implement CLAMP-04, until they are. + +## GAP-1 — CLAMP-04 contradicts tests that `030_test_impact.md` promises to keep green + +CLAMP-04 says a leftover diagnostic listing only `max`/`ultra` should stop counting +as an active clamp. But `tests/codex-integration/codex-runtime.test.ts:601` and +`:624` seed `persistEffortClamp` with exactly `removedEfforts: ["max","ultra"]` and +`["max"]` and then assert the diagnostic **is** active. `030_test_impact.md` lists +both as must-keep-passing. Both cannot be true. + +Two more files assert the same leftover shape live and are missing from `030` +entirely: `tests/cli/cli-status-json.test.ts:376` and +`tests/config/settings-stream-mode.test.ts:145`. + +Phase 1's accept criteria also require same-path-same-version to still return +`true` — which is precisely the leftover file on the reporting machine. + +**Disposition: RESOLVED 2026-09-11 — CLAMP-04 ships.** The user authorised the full +cycle with the working tree already carrying the CLAMP-04 implementation +(`liveRemovedEfforts` in `src/codex/runtime.ts`, doctor/status/config-routes aligned +to it, and the four seed-test rows inverted to `["xhigh"]`). That is the recorded +decision; see `050_revalidation.md`. + +Independent of that choice: `src/cli/doctor.ts:1180` did not call +`effortClampAppliesToRuntime`, so doctor and status could disagree about one file. +**Historical as of 2026-09-11** — `doctor.ts:1182-1189` now calls the shared +predicate; recorded in `050_revalidation.md`. + +## GAP-2 — Phase 3 names a consumer that cannot consume + +The reflection is right that the field chain skips a stage. The roster result is a +`ReadonlySet` on `CodexModelEntitlementSnapshot.modelsByAccount`; there is no +presentation payload anywhere until that cache shape changes. +`finishUpstreamNativeEntry` (`sync.ts:257`) clones the pin and takes no roster data, +so naming it as the consumer describes a path that does not exist. The missing +stages are fetch → snapshot shape → sync plumbing, and the plan names none of them. + +Worse for the stated goal: **Astra is not account-gated.** +`ACCOUNT_GATED_NATIVE_OPENAI_MODELS` (`src/codex/catalog/native-models.ts:50`) is +Daybreak alone, and `availableAccountGatedNativeModels` +(`model-entitlements.ts:1074`) filters only that set. So "a native slug the roster +already authorises" excludes the one model this whole thread is about. Overlaying +roster copy onto Astra is a new use of `/models`, not a descriptor on an existing +allowlist. + +And Daybreak — the one slug the roster does authorise — is a capability alias whose +`availability_nux` is deleted at `metadata.ts:567`. Phase 3 asserts the alias still +loses the field while also asserting the roster wins over the pin. Merge order is +unspecified, so those two accept rows can contradict each other. + +**Disposition: Phase 3 is withdrawn from the executable plan** and reduced to a +question: does `backend-api/codex/models?client_version=0.154.0` return +`availability_nux` for `gpt-6-astra` under a real account? If no, the whole phase +dies and the answer to "why is there no Astra card" is simply that upstream has not +shipped copy for it. If yes, Phase 3 is re-planned from the snapshot shape up, not +patched into `finishUpstreamNativeEntry`. + +## GAP-3 — citation nits + +- `020` cites `codex-catalog.test.ts:7398` for the native-keeps-eligibility pin; `:7398` is the comment, the test is `:7400`. +- `020` cites Sol's `availability_nux` assertion at `:3529`; it is `:3530`. +- `030` lists `client-catalog-compatibility.test.ts:97` as a fourth `#4207` case; it is an assertion inside the test at `:83`. + +Left uncorrected in place deliberately — the reflection is the record, and rewriting +the numbers without re-reading the files would be the same class of error. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md new file mode 100644 index 0000000000..b034e7de09 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md @@ -0,0 +1,96 @@ +# P revalidation — second session, 2026-09-11 + +Session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8` (hook-bound, CODEX_THREAD_ID verified +against the SessionStart line) resumed this unit after the first session stopped at +plan-only. This document is the P-phase revalidation record for the resumed cycle. + +## What changed since the plan was written + +1. **The user authorised the full cycle.** The first session's stop condition was + plan-only; the user then instructed this session to proceed (`진행해줘`) and to + use `xai/grok-4.6` subagents without a cap. `000_plan.md` loop spec updated. +2. **Phases 1 and 4 are already implemented, uncommitted**, on branch + `codex/260911-clamp-expiry` (base: `dev` @ `babb76449`). `git diff` shows: + - `src/codex/runtime.ts` — `liveRemovedEfforts` + `UNCLAMPABLE_REASONING_EFFORTS`; + `effortClampAppliesToRuntime` is now version-aware on the same-path branch + (Phase 1) and inert when only `max`/`ultra` are named (CLAMP-04). + - `src/cli/doctor.ts`, `src/cli/status.ts`, `src/server/management/config-routes.ts` + — all three surfaces now read the same predicate (Phase 4 core). + - Tests inverted per GAP-1: `codex-runtime.test.ts` (601-region seeds changed to + `["xhigh"]`, three new tests), `cli-status-json.test.ts:396,421`, + `settings-stream-mode.test.ts:163,190`. + The authorship of this diff is not recorded anywhere this session can see — no + other cxc session file exists and the ledger has no B entry. Treated as user- + authorised work in progress and adopted as this cycle's B baseline. +3. **GAP-1 resolved: CLAMP-04 ships.** The tree is the decision. See `040_open_gaps.md`. +4. **Phase 3 stays withdrawn** (GAP-2). The account-roster probe needs a live ChatGPT + token; not attempted. + +## Remaining B scope (revalidated against `030_test_impact.md`) + +- `src/codex/catalog/effort.ts` — the CLAMP-01 predicate at the keep-filter + (`clampEntryToCodexSupportedEfforts`, :357), gated default-repair at BOTH + `:363-367` (Reserve branch) and `:378` (shared block). The CLAMP-05 reserve keep + falls out of the filter — `:466,472` are effects, NO splice special-case + (A-audit round 2 correction). **Not yet implemented** — the tree diff does + not touch `effort.ts`, so until B lands it, `liveRemovedEfforts` is a forward + reference and status/doctor would under-report a clamp that sync still applies. +- Test inversions still pending: `codex-catalog.test.ts` :7051, :7077, :7095, :7107; + `codex-runtime.test.ts:1005`; reserve keep-case added near `reserve-catalog.test.ts:239`. +- Must-stay-green: `reserve-catalog.test.ts:239,252`, + `client-catalog-compatibility.test.ts:37,51,76,97` (CLAMP-02 / #4207 gate), + `codex-catalog.test.ts:7086,7115`. +- Docs-site: Phase 2 changes user-visible behaviour, so a docs note is owed (Phase 4). + +## Verifier re-run + +`bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` +— attempted at P; queued behind a concurrent `bun run test:changed` (pid 11292, +started 12:20:56 by a process outside this session). Result recorded in C with the +fresh run. The verifier command exists and reads the target (unchanged from `010_evidence.md` §5). + +## Collision note + +A `bun run test:changed` run owned by another process is active in this working +tree. This session re-checks `git status`/`git diff` before every B edit and does +not revert hunks it did not write. +## A-audit round 1 synthesis (2026-09-11, reviewer `xai/grok-4.6` "Tesla") + +VERDICT: FAIL, two blockers. Both accepted, none rebutted. + +1. **Reserve keep must fall out of the `:357` keep-filter, not a `:466/:472` splice + exception.** Correct — a splice special-case would leave an empty-ladder Reserve + row. `000_plan.md` Phase 2 mechanism and CLAMP-05 paragraphs rewritten: the + predicate site is the filter at `effort.ts:357`; the Reserve default-repair is + `:363-367` (not `:378`, which is unreachable for Reserve because of the early + return); `:466/:472` are pure effects. +2. **Phases 1+4 must not land without Phase 2.** Correct — `liveRemovedEfforts` + already hides rungs the clamp still removes. Recorded as a landing constraint in + `000_plan.md`: one branch, one landing. + +Reviewer-verified facts folded into `030_test_impact.md`: the default-repair test +lives at `codex-runtime.test.ts:1005` (not 940); the must-keep persist-seed line +numbers are stale and those tests are located by name. + +Verifier baselines the reviewer ran fresh: Phase 1 pair 45 pass / 0 fail; Phase 2 +gate trio 346 pass / 0 fail (pre-CLAMP-01 baseline — inverting them is B's work). + +## B-phase discoveries (2026-09-11) + +- **030's invert list missed one test.** `bun run test:changed` caught + `codex-convergence-account-selectors.test.ts:916` ("convergence clamps native, routed, + and account rows to observed runtime support") still expecting `max`/`ultra` stripped. + Inverted: the four rows now assert the surviving-rungs invariant (observed ∪ + {max,ultra}), and the full-ladder routed row proves the exemption ran (ladder and + `ultra` default verbatim). The generic invariant replaced a blanket `toContain` because + account-projection rows legitimately ship narrow ladders (`["medium","max"]`) — the + exemption preserves, never adds. +- **Pre-existing environmental failures, proved on base.** `test:changed` also failed + cursor-integration-status (gateway `apiKeyMode`), update-pnpm ×3 (EFAULT / POSIX shims + on Windows), and a 5s bearer-admission timeout. A pristine worktree at the merge base + (`babb76449`) fails the same five, so they are not this diff's. Worktree removed after + the check. +- **codexclaw tooling issue filed.** `cxc session current`/`session bind` cannot resolve + the native session cwd on this desktop install (CODEX_THREAD_ID is set and matches the + SessionStart binding): https://github.com/lidge-jun/codexclaw/issues/134 +*** End Patch diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md new file mode 100644 index 0000000000..0691a81241 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md @@ -0,0 +1,54 @@ +# Done — catalog presentation and effort projection (cycle 1) + +2026-09-11, session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8`. Reader: someone who was +not in the loop. + +## Conclusion + +Phases 1, 2 and 4 landed on `codex/260911-clamp-expiry` and every gate that observes +them is green. Phase 3 (account-roster presentation fields) stays withdrawn: whether +`backend-api/codex/models` carries card copy the pin lacks is still unverified, and +the probe needs a live ChatGPT token, which is the user's call. + +## What changed + +- `fddbb7fad` — the code. `src/codex/runtime.ts` exports the single + `UNCLAMPABLE_REASONING_EFFORTS` set (`max`/`ultra`); `effortClampAppliesToRuntime` is + version-aware on the same-path branch (in-place Windows upgrades no longer keep a + stale diagnostic alive) and inert when only exempt rungs are named; + `src/codex/catalog/effort.ts:357` keeps those rungs in the observed-runtime + intersection and `:378` stops repairing an exempt default; the Reserve keep falls + out of the filter with no splice special-case; `ocx status`, `ocx doctor` and + `/api/settings` read one shared predicate. +- `docs(devlog)` commit — this unit (000-060). + +## Evidence + +- Focused gate: `bun test` on the 8 affected files — 503 pass / 0 fail, exit 0. +- `bun run typecheck` — exit 0. +- `bun run test:changed` — 14080 pass; the 4 unique failures (cursor `apiKeyMode`, + pnpm ×3, one 5s bearer timeout) were reproduced on a pristine worktree at merge + base `babb76449`, so they pre-date this diff. +- Activation observed live on the reporting machine: repo build prints `Catalog + clamp: inactive` and no doctor warning against the real leftover 0.135.0 + diagnostic at the unchanged binary path (now 0.154.0). +- Audit: three rounds with the same independent reviewer (`xai/grok-4.6`), + FAIL → FAIL → PASS; synthesis in `050_revalidation.md`. + +## What did not improve (LOOP-PESSIMIST-01) + +- The installed proxy (2.50.0) still ships the old behaviour; this machine's warning + clears only for a build that includes this branch. +- The four environmental test failures are untouched — they are not this unit's, + but they are also nobody's right now. +- The Astra card question is narrowed, not answered: bundled and pin both carry + `availability_nux: null` for `gpt-6-astra`, so if upstream ships copy at all it + lives on the account roster endpoint. If the probe shows nothing there either, + the honest answer to #4213 is that upstream has not shipped the copy. + +## Next + +- Push + PR to `dev` — needs explicit user approval (DEV-GIT-PUSH-01). PR text must + fill the template; no GUI surface changed, so no screenshot obligation. +- The account-roster probe (Phase 3 re-plan trigger) — user-authorized token only. +- A summary comment on #4213 with the catalog-field evidence — offered, not requested. diff --git a/devlog/_plan/260911_hub_single_port/010_launchd_repair.md b/devlog/_plan/260911_hub_single_port/010_launchd_repair.md new file mode 100644 index 0000000000..15d88e421d --- /dev/null +++ b/devlog/_plan/260911_hub_single_port/010_launchd_repair.md @@ -0,0 +1,421 @@ +# PR1 — macOS launchd repair/status (issue #4236 defects 1 & 2) + +Branch `codex/260911-l4-launchd-repair`, based on `dev` (`babb76449`). First of the four-PR +hub single-port stack; the others target this branch's head in turn. + +Scope: `src/service.ts` macOS path only, plus the two shared test-safety guards the work +uncovered. Defects 3 and 4 from the issue (secondary-port misdiagnosis, `ocx status` fence +comparison) are deliberately left to PR2, which owns the loopback listener. + +Three rounds. The first is the three commits below; the second folds in a review of them, and +every fix it carries is marked in place; the third (section E) makes `ocx service restart` +actually restart, which the second round's no-op had quietly turned into a no-op of its own. In short: the new protocol was still asking the OLD +two-state `launchdJobMatchesPlist` at both decision points (so an unreadable `launchctl print` +still evicted a healthy hub, twice), the no-op pre-check compared whole-file bytes while +`buildPlist` bakes the repairing process's `PATH`, the comment justifying `kickstart -k` was +wrong about launchd re-reading the plist, and every mutating verb still addressed `gui/` +alone while the new probe reports `user/` too. + +## What shipped + +### A — `installLaunchd()` (repair must not be an outage) + +1. **No-op pre-check, on the TRI-STATE probe.** The plist is rendered BEFORE anything is + written. If the rendered bytes equal the on-disk bytes, the data-token file is unchanged, + and `probeLaunchdLoadState()` answers `loaded-current`, the function re-asserts 0600 on the + plist, refreshes install state, logs + `service is already loaded from the current plist; nothing to do.` and returns. launchd is + not touched at all. This is the headline fix: a repair of a healthy hub used to evict it + unconditionally. + + Two things the first round got wrong here, both found in review: + + - It asked `launchdJobMatchesPlist`, which reports `loaded:false` for EVERY non-zero + `launchctl print` — EPERM from a non-Aqua ssh/cron context, an unspawnable launchctl, an + undocumented status. On a healthy serving hub that read as "not loaded", so the pre-check + evicted, the verification failed the same way, the rollback evicted again and the error + ended with "IS NOT RUNNING" about a job that was up. Both checks now go through + `probeLaunchdLoadState`, and `unknown` refuses to touch launchd at all (item 9). + - It compared whole-file bytes, while `buildPlist` bakes `process.env.PATH` from whichever + process is repairing. A tray helper, `ocx update`'s child or an ssh session carries a + different PATH, so the pre-check missed and the healthy hub was evicted *and* had its + PATH narrowed. `reusePreviousPlistPathVariable()` now puts the installed PATH back when + PATH is the ONLY difference and the live job runs the exec line this install baked — the + two files then compare equal on their own terms, and the PATH the service already runs + with survives. Anything else differing means a real rewrite, PATH included. +2. **Backup + rollback.** The previous plist bytes are held in memory and copied to + `.prev` before the overwrite. On terminal failure the bytes go back, a + bootout/settle/bootstrap tries to re-register them, and the thrown error states whether + that worked. +3. **`bootstrap gui/$uid ` replaces `load -w`.** `bootout` was already + domain-explicit; `load` acts on the CALLER's bootstrap domain, so from ssh/cron/another + bootstrap context the old pair deleted the gui-domain job and registered nothing. +4. **Bounded settle after an eviction that evicted something** — up to 5 × 200 ms while + `launchctl print ` still answers 0, the launchd twin of the Windows + `SCHEDULER_SETTLE_DELAYS_MS` idea. `bootout` is asynchronous, so the old back-to-back + retry raced the same exiting job twice and added nothing. A `bootout` that exited 3 is not + settled: nothing is exiting to wait for. +5. **Success is `probeLaunchdLoadState()` answering `loaded-current`, never stderr.** It is + asked against the command this install actually baked, and `writeServiceInstallState` + runs only after it agrees. Stderr regexes are advisory routing signals now. +6. **One retry, routed by the failure.** Exit 5 / `Bootstrap failed` → `kickstart -k` (only + when the rendered bytes are already on disk, see item 10), then `enable` + a second + bootout/bootstrap (see the launchctl findings below). Exit 0 with a disagreeing probe → + one more bootout/bootstrap, because that is the silent no-op. Any other failure (malformed + plist, EPERM) throws immediately so the real stderr reaches the operator undelayed — the + property the previous code had and kept. A probe that answered `unknown` is NOT retried: a + retry is another eviction. +7. **Error text names what the probe actually found.** `not-loaded`: the job was evicted from + `gui/` and is not running (or the previous plist was restored and re-bootstrapped). + `loaded-stale`: it *is* loaded, from a different command than the plist just written — + telling that operator "nothing is listening" sends them to fix the wrong thing. Both name + `launchctl bootstrap gui/ ` as the remedy, plus `launchctl print` and + `launchctl print-disabled` to inspect. +8. **`stableLauncherEntry()` prefers the recorded launcher** when it is still an absolute + executable file, falling back to the PATH walk otherwise (defect 1g). A repair from a + context without `ocx` on PATH no longer rewrites a working launcher-form plist into the + version-pinned Bun + CLI pair. + + **This is not a macOS-only change.** `installSystemd` resolves the same function, so a + Linux repair from a PATH-less context now keeps the `ExecStart` the unit already has. The + failure it prevents there is milder (systemd reloads and restarts; it never evicts into + nothing), but the silent rewrite was identical, so the behaviour is deliberately shared + rather than branched. `tests/service/service.test.ts` covers the systemd side directly. +9. **An unverifiable launchd state refuses to act.** `unknown` from the pre-check throws + before a single file is written or a single verb is run, saying the job may be RUNNING and + naming `launchctl print gui//