From de9cae88847aaf23843dbaced283d69917a7dc1d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:11:46 +0900 Subject: [PATCH 01/12] test(service): add disposable-host composed acceptance --- .../260826_wp13_disposable_host/010_census.md | 56 ++++ .../codex-service-composed-acceptance.ts | 314 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 devlog/_plan/260826_wp13_disposable_host/010_census.md create mode 100644 scripts/disposable-host/codex-service-composed-acceptance.ts diff --git a/devlog/_plan/260826_wp13_disposable_host/010_census.md b/devlog/_plan/260826_wp13_disposable_host/010_census.md new file mode 100644 index 0000000000..5105553718 --- /dev/null +++ b/devlog/_plan/260826_wp13_disposable_host/010_census.md @@ -0,0 +1,56 @@ +# #1048 production-entry census + +Rechecked against `origin/dev` at `aacd6528d8` on 2026-08-26. “Covered” +means the named test invokes the production entry or the disposable runner does. +Rows retired from *additional composed execution* retain a concrete regression or +inventory proof and a reason why another process-level case would duplicate the +same receiving production edge. + +| ID | Disposition | Evidence | +|---|---|---| +| P01 | Retired from additional composed execution | `src/cli/init.ts` calls the same native apply convergence exercised by P02/P08 after saving config; `tests/init-eof.test.ts` owns the unique interactive/EOF boundary. No independent writer remains. | +| P02 | Covered | `tests/codex-composed-acceptance.test.ts` — “A-reduced: real CLI and HTTP entry points preserve an OFF Codex config/home”. | +| P03 | Retired from additional composed execution | `tests/shutdown-launcher.test.ts` — “ocx launcher graceful shutdown”; shutdown cleanup uses the same remove convergence proven by P07. | +| P04 | Covered | `tests/codex-composed-acceptance.test.ts` — “A-reduced: real CLI and HTTP entry points preserve an OFF Codex config/home”. | +| P05 | Covered | Same A-reduced case invokes real `ocx sync`. | +| P06 | Covered | Same A-reduced case invokes real `ocx sync-cache`. | +| P07 | Covered | The A-reduced case invokes real `ocx restore`; D-reduced invokes the same row under foreign ownership. | +| P08 | Covered | Same A-reduced case invokes real `ocx restore back`. | +| P09 | Covered (disposable only) | `scripts/disposable-host/codex-service-composed-acceptance.ts` row P09, real `ocx stop`, exact +1 remove transaction. | +| P10 | Covered (disposable only) | Disposable runner row P10, real `ocx uninstall`, exact +1 remove transaction before owned-state deletion. | +| P11 | Retired from additional composed execution | `tests/cli-help.test.ts` — “recover-history requires exact confirmation before mutating history”; this command owns a history-only job, not a native mutation, so it cannot satisfy Scenario A's native-transaction oracle. | +| P12 | Retired from additional composed execution | `tests/cli-provider.test.ts` — “provider add --sync flag is accepted without error” and “provider add --sync --json reports needsSync false”; live sync receives P19/P05's catalog convergence. | +| P13 | Retired from additional composed execution | `tests/cli-models.test.ts` — “models add accepts slash model ids”; its live branch calls the same management/catalog convergence inventoried below. | +| P14 | Retired from additional composed execution | `tests/cli-models.test.ts` — “an unambiguous slash selector still removes its row”; same receiving convergence as P13. | +| P15 | Retired from additional composed execution | `tests/codex-v2-gate.test.ts` — “off -> on carries the active legacy value and removes the boot conflict”; mode mutation is independently tested and its sync tail is P05/P19. | +| P16 | Retired from additional composed execution | `tests/codex-v2-gate.test.ts` — “on -> off carries the active v2 value and removes v2 limit storage”; same sync tail as P15. | +| P17 | Covered | P02 in A-reduced and “Grok E2E: route-disabled Grok stays absent across a real startup” execute startup reconciliation in real child processes. | +| P18 | Covered (disposable only) | Disposable runner row P18, authenticated real `POST /api/stop`, exact +1 remove transaction. | +| P19 | Covered | A-reduced, B-reduced, D-reduced, and D-unknown send real authenticated `POST /api/sync` to a real server. | +| P20 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider POST overwrite preserves modelCosts when the payload omits it”; `tests/codex-convergence-contract.test.ts` — exact route-inventory test proves its convergence call. | +| P21 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider PATCH field-mask edits non-reserved providers and rejects unsafe fields (WP040)”; route inventory proves convergence. | +| P22 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider deletion removes stale provider context caps”; route inventory proves convergence. | +| P23 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider context-cap API supports global value and set-all toggles”; all three branches are counted by the exact route inventory. | +| P24 | Retired from another process case | `tests/native-model-toggle.test.ts` — “management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs”; exact route inventory proves convergence. | +| P25 | Retired from another process case | `tests/model-visibility-management-api.test.ts` — “enables excluded or blocked models and disables without erasing the allowlist”; exact route inventory proves convergence. | +| P26 | Retired from another process case | Custom-model create is one of the exact `7 + 13 + 2 + 2` calls asserted by `tests/codex-convergence-contract.test.ts`; its receiving commit is covered by P19. | +| P27 | Retired from another process case | Custom-model update is in the same exact route inventory; no direct writer remains outside management convergence. | +| P28 | Retired from another process case | Custom-model delete is in the same exact route inventory; no direct writer remains outside management convergence. | +| P29 | Retired from another process case | `tests/model-visibility-management-api.test.ts` — “uses raw allowlist ids, canonical routed slugs, and rejects invalid requests”; exact route inventory proves convergence. | +| P30 | Retired from another process case | `tests/combo-management-api.test.ts` — “PUT and DELETE clear only the mutated combo cooldowns”; `codex-convergence-contract` proves both alias-write routes converge. | +| P31 | Retired from another process case | `tests/combo-management-api.test.ts` — “DELETE refresh immediately retires the final managed combo catalog row”; route inventory proves convergence. | +| P32 | Retired from another process case | Agent-settings write is included in the exact convergence-call inventory; V2 mutation semantics are covered by `tests/codex-v2-gate.test.ts`. | +| P33 | Retired from another process case | `tests/subagent-roster-retention.test.ts` — “retained roster entries are appended once, after the selectable models”; exact route inventory proves convergence and the follow-up remains independently tested. | +| P34 | Covered (disposable only) | Disposable runner row P34, fixture install/stop then real `ocx service start`, exact +1 apply transaction. | +| P35 | Covered (disposable only) | Disposable runner row P35, real `ocx service stop`, exact +1 remove transaction. | +| P36 | Covered (disposable only) | Disposable runner row P36, real `ocx service uninstall`, exact +1 remove transaction. | + +## Summary + +- 14 rows have direct composed process coverage: P02, P04-P10, P17-P19, + P34-P36. +- 22 rows are explicitly retired from an additional composed process case. + Their command/route semantics remain covered, and their write tail is either + the already-composed convergence seam or the exact static route-call inventory. +- The six service rows are not accepted as passing until the disposable script + runs green on a sentinel-provisioned host and its final empty gate is captured. diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts new file mode 100644 index 0000000000..3440819dc5 --- /dev/null +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -0,0 +1,314 @@ +/** + * Disposable-host composed acceptance for the six globally addressed service rows. + * + * This is intentionally a script, not a Bun test. It mutates the current account's + * systemd user registration and therefore refuses to run without the root-owned + * image sentinel specified by WP13. + */ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { Database } from "bun:sqlite"; + +const SENTINEL = "/etc/opencodex-disposable-service-host-v1"; +const SENTINEL_BYTES = "OPENCODEX_DISPOSABLE_SERVICE_HOST_V1\n"; +const UNIT = "opencodex-proxy.service"; +const repoRoot = resolve(import.meta.dir, "../.."); +const cliPath = join(repoRoot, "src/cli/index.ts"); +const accountHome = homedir(); +const accountUnit = join(accountHome, ".config/systemd/user", UNIT); +const eventLedger: string[] = []; + +type RowId = "P09" | "P10" | "P18" | "P34" | "P35" | "P36"; +type ChildResult = { exitCode: number; stdout: string; stderr: string }; +type Transition = { nativeGeneration: number; currentTxId: string | null; direction: string | null }; + +function fail(message: string): never { + throw new Error(message); +} + +function assertDisposableSentinel(): void { + const link = lstatSync(SENTINEL); + const stat = statSync(SENTINEL); + if (!link.isFile() || link.isSymbolicLink()) fail(`${SENTINEL} must be a non-symlink regular file`); + if (stat.uid !== 0) fail(`${SENTINEL} must be root-owned (uid=${stat.uid})`); + if ((stat.mode & 0o022) !== 0) fail(`${SENTINEL} must not be group/world writable (mode=${(stat.mode & 0o777).toString(8)})`); + if (readFileSync(SENTINEL, "utf8") !== SENTINEL_BYTES) fail(`${SENTINEL} has unexpected bytes`); + eventLedger.push("sentinel:verified"); + console.log(`SENTINEL verified ${SENTINEL}`); +} + +async function spawnResult(argv: string[], options: { cwd?: string; env?: Record } = {}): Promise { + const child = Bun.spawn(argv, { + cwd: options.cwd ?? repoRoot, + env: options.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; +} + +async function requireCommand(argv: string[], label: string): Promise { + const result = await spawnResult(argv); + eventLedger.push(`query:${label}`); + if (result.exitCode !== 0) { + fail(`${label} unavailable (exit ${result.exitCode}): ${result.stderr || result.stdout}`); + } + return result; +} + +async function emptyRegistrationGate(extraArtifact?: string): Promise { + if (eventLedger[0] !== "sentinel:verified") fail("service query attempted before sentinel verification"); + const units = await requireCommand( + ["systemctl", "--user", "list-unit-files", UNIT, "--no-legend", "--no-pager"], + "systemctl list-unit-files", + ); + if (units.stdout.trim()) fail(`service registration is nonempty: ${units.stdout.trim()}`); + + const status = await spawnResult(["systemctl", "--user", "status", UNIT, "--no-pager"]); + eventLedger.push("query:systemctl status"); + const statusText = `${status.stdout}\n${status.stderr}`; + if (status.exitCode === 0 || !/could not be found|not-found|not found|loaded: not-found/i.test(statusText)) { + fail(`service status did not prove unit-not-found (exit ${status.exitCode}): ${statusText.trim()}`); + } + for (const artifact of [accountUnit, extraArtifact].filter((value): value is string => Boolean(value))) { + if (existsSync(artifact)) fail(`service artifact exists: ${artifact}`); + } + console.log(`GATE empty registration (${extraArtifact ?? accountUnit})`); +} + +function byteManifest(root: string): Record { + const entries: Record = {}; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const key = relative(root, path); + if (stat.isDirectory()) walk(path); + else if (stat.isFile()) entries[key] = createHash("sha256").update(readFileSync(path)).digest("hex"); + else entries[key] = `non-file:${stat.mode}`; + } + }; + walk(root); + return entries; +} + +function sameManifest(a: Record, b: Record, label: string): void { + if (JSON.stringify(a) !== JSON.stringify(b)) fail(`${label} byte manifest changed`); +} + +function coordinatorPath(codexHome: string): string { + const canonical = realpathSync.native(codexHome); + const digest = createHash("sha256").update(canonical).digest("hex"); + return join(`/tmp/opencodex-runtime-v1-${process.getuid!()}`, "native-write-locks", `${digest}.sqlite`); +} + +class Fixture { + readonly root = mkdtempSync(join(tmpdir(), "ocx-service-composed-")); + readonly home = join(this.root, "home"); + readonly userprofile = join(this.root, "userprofile"); + readonly codex = join(this.root, "codex"); + readonly ocx = join(this.root, "ocx"); + readonly runtime = `/run/user/${process.getuid!()}`; + readonly unit = join(this.home, ".config/systemd/user", UNIT); + readonly lock: string; + readonly lockAllowlist: string[]; + readonly baselineOutside: Record; + + constructor(readonly row: RowId) { + for (const path of [this.home, this.userprofile, this.codex, this.ocx]) mkdirSync(path, { recursive: true, mode: 0o700 }); + writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); + writeFileSync(join(this.ocx, "config.json"), JSON.stringify({ + port: 0, + hostname: "127.0.0.1", + syncResumeHistory: false, + claudeCode: { systemEnv: false }, + clientIntegrations: { codex: true, grok: false, "claude-desktop": false }, + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: false, + models: ["fixture-model"], + }, + }, + defaultProvider: "fixture", + }, null, 2)); + this.lock = coordinatorPath(this.codex); + this.lockAllowlist = [this.lock, `${this.lock}-journal`, `${this.lock}-wal`, `${this.lock}-shm`]; + for (const path of this.lockAllowlist) if (existsSync(path)) fail(`${row}: pre-existing coordinator artifact: ${path}`); + this.baselineOutside = this.outsideManifest(); + } + + env(): Record { + return { + HOME: this.home, + USERPROFILE: this.userprofile, + CODEX_HOME: this.codex, + OPENCODEX_HOME: this.ocx, + XDG_RUNTIME_DIR: this.runtime, + OPENCODEX_API_AUTH_TOKEN: "disposable-data-token", + OPENCODEX_ADMIN_AUTH_TOKEN: "disposable-admin-token", + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + NO_PROXY: "127.0.0.1,localhost", + CI: "true", + }; + } + + outsideManifest(): Record { + const result: Record = {}; + for (const path of [accountUnit, ...this.lockAllowlist]) { + result[path] = existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : "absent"; + } + return result; + } + + async cli(args: string[]): Promise { + const result = await spawnResult([process.execPath, cliPath, ...args], { cwd: this.root, env: this.env() }); + if (result.exitCode !== 0) fail(`${this.row}: ocx ${args.join(" ")} failed (${result.exitCode})\n${result.stderr}\n${result.stdout}`); + return result; + } + + transition(): Transition { + if (!existsSync(this.lock)) return { nativeGeneration: 0, currentTxId: null, direction: null }; + const db = new Database(this.lock, { readonly: true }); + try { + const row = db.query(`SELECT native_generation, current_tx_id, history_direction FROM codex_transition_state WHERE singleton = 1`).get() as Record | null; + if (!row) fail(`${this.row}: coordinator transition row is missing`); + return { + nativeGeneration: Number(row.native_generation), + currentTxId: row.current_tx_id === null ? null : String(row.current_tx_id), + direction: row.history_direction === null ? null : String(row.history_direction), + }; + } finally { + db.close(); + } + } + + async install(): Promise { + await this.cli(["service", "install"]); + if (!existsSync(this.unit)) fail(`${this.row}: install did not create fixture unit`); + } + + async waitForRuntime(): Promise<{ port: number; pid: number }> { + const path = join(this.ocx, "runtime-port.json"); + for (let attempt = 0; attempt < 300; attempt++) { + if (existsSync(path)) { + const value = JSON.parse(readFileSync(path, "utf8")) as { port?: number; pid?: number }; + if (Number.isInteger(value.port) && Number.isInteger(value.pid)) return value as { port: number; pid: number }; + } + await Bun.sleep(20); + } + fail(`${this.row}: timed out waiting for runtime-port.json`); + } + + async apiStop(): Promise { + const runtime = await this.waitForRuntime(); + const script = `const r=await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)},{method:"POST",headers:{"x-opencodex-api-key":"disposable-admin-token"}});console.log(r.status,await r.text());if(!r.ok)process.exit(1)`; + const result = await spawnResult([process.execPath, "--eval", script], { cwd: this.root, env: this.env() }); + if (result.exitCode !== 0) fail(`${this.row}: POST /api/stop failed\n${result.stderr}\n${result.stdout}`); + return result; + } + + assertOneTransaction(before: Transition, expectedDirection: "apply" | "remove"): Transition { + const after = this.transition(); + if (after.nativeGeneration !== before.nativeGeneration + 1) { + fail(`${this.row}: expected exactly one admitted transaction; generation ${before.nativeGeneration} -> ${after.nativeGeneration}`); + } + if (!after.currentTxId || after.currentTxId === before.currentTxId) fail(`${this.row}: transaction id did not advance`); + if (after.direction !== expectedDirection) fail(`${this.row}: expected ${expectedDirection} transaction, got ${after.direction}`); + return after; + } + + async teardown(): Promise { + const cleanup = await spawnResult([process.execPath, cliPath, "service", "uninstall"], { cwd: this.root, env: this.env() }); + if (cleanup.exitCode !== 0 && existsSync(this.unit)) { + fail(`${this.row}: fixture service teardown failed\n${cleanup.stderr}\n${cleanup.stdout}`); + } + await emptyRegistrationGate(this.unit); + for (const path of this.lockAllowlist) if (existsSync(path)) unlinkSync(path); + sameManifest(this.outsideManifest(), this.baselineOutside, `${this.row}: outside-temp-root`); + rmSync(this.root, { recursive: true }); + } +} + +async function runRow(row: RowId): Promise { + await emptyRegistrationGate(); + const fx = new Fixture(row); + let completed = false; + try { + await fx.install(); + let before: Transition; + let output: ChildResult; + let direction: "apply" | "remove"; + if (row === "P34") { + await fx.cli(["service", "stop"]); + before = fx.transition(); + output = await fx.cli(["service", "start"]); + direction = "apply"; + } else { + before = fx.transition(); + direction = "remove"; + if (row === "P09") output = await fx.cli(["stop"]); + else if (row === "P10") output = await fx.cli(["uninstall"]); + else if (row === "P18") output = await fx.apiStop(); + else if (row === "P35") output = await fx.cli(["service", "stop"]); + else output = await fx.cli(["service", "uninstall"]); + } + const after = fx.assertOneTransaction(before, direction); + const recordPath = join(fx.ocx, "integrations/codex.json"); + if (row === "P10") { + // Full uninstall deliberately removes the owned OPENCODEX_HOME only after the + // native removal transaction succeeds. Requiring its record to survive would + // contradict the production command's contract. + if (existsSync(fx.ocx)) fail(`${row}: full uninstall left owned OpenCodex state behind`); + } else { + if (!existsSync(recordPath)) fail(`${row}: integration record is missing`); + const record = JSON.parse(readFileSync(recordPath, "utf8")) as { provenance?: { entries?: Array<{ txId?: string }> } }; + const matching = record.provenance?.entries?.filter(entry => entry.txId === after.currentTxId).length ?? 0; + if (matching === 0) fail(`${row}: admitted transaction has no provenance entry`); + } + console.log(`${row} PASS generation=${before.nativeGeneration}->${after.nativeGeneration} direction=${direction} tx=${after.currentTxId}`); + console.log(`${row} OUTPUT ${output.stdout.trim().replace(/\s+/g, " ").slice(0, 500)}`); + completed = true; + } finally { + await fx.teardown(); + if (!completed) console.error(`${row} FAIL (teardown completed)`); + } +} + +async function main(): Promise { + if (process.platform !== "linux") fail(`this disposable runner currently requires Linux/systemd, got ${process.platform}`); + assertDisposableSentinel(); + await requireCommand(["systemctl", "--version"], "systemctl version"); + for (const row of ["P09", "P10", "P18", "P34", "P35", "P36"] as const) await runRow(row); + await emptyRegistrationGate(); + console.log(`PASS disposable service census: ${["P09", "P10", "P18", "P34", "P35", "P36"].join(", ")}`); + console.log(`EVENTS ${eventLedger.join(" | ")}`); +} + +main().catch(error => { + console.error(`FAIL disposable service acceptance: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + process.exitCode = 1; +}); From daf6e6fc75223090939573fd6dfa2619e52a27ab Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:12:55 +0900 Subject: [PATCH 02/12] fix(service): distinguish empty systemd lookup --- .../codex-service-composed-acceptance.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index 3440819dc5..458e10543d 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -78,10 +78,15 @@ async function requireCommand(argv: string[], label: string): Promise { if (eventLedger[0] !== "sentinel:verified") fail("service query attempted before sentinel verification"); - const units = await requireCommand( - ["systemctl", "--user", "list-unit-files", UNIT, "--no-legend", "--no-pager"], - "systemctl list-unit-files", - ); + // Ubuntu's systemctl returns 1 (with no output) when a name filter matches no + // unit. Prove the bus independently so that result cannot hide a permission or + // connectivity failure, then accept only the measured empty 0/1 result. + await requireCommand(["systemctl", "--user", "show-environment"], "systemctl user bus"); + const units = await spawnResult(["systemctl", "--user", "list-unit-files", UNIT, "--no-legend", "--no-pager"]); + eventLedger.push("query:systemctl list-unit-files"); + if ((units.exitCode !== 0 && units.exitCode !== 1) || units.stderr.trim()) { + fail(`systemctl list-unit-files unavailable (exit ${units.exitCode}): ${units.stderr || units.stdout}`); + } if (units.stdout.trim()) fail(`service registration is nonempty: ${units.stdout.trim()}`); const status = await spawnResult(["systemctl", "--user", "status", UNIT, "--no-pager"]); From 90998e70a6dde249b39a38ebb8844351c08d3a48 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:13:47 +0900 Subject: [PATCH 03/12] chore(service): trim acceptance imports --- scripts/disposable-host/codex-service-composed-acceptance.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index 458e10543d..ba526530db 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -7,7 +7,6 @@ */ import { createHash } from "node:crypto"; import { - chmodSync, existsSync, lstatSync, mkdirSync, @@ -21,7 +20,7 @@ import { writeFileSync, } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import { Database } from "bun:sqlite"; const SENTINEL = "/etc/opencodex-disposable-service-host-v1"; From 9cd16f429699776c5e6bbb96d36013b05c0c20af Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:17:14 +0900 Subject: [PATCH 04/12] fix(acceptance): run service rows against the real account home A fake HOME was the obvious symmetry with every other fixture path, and it does not work: systemctl --user resolves its unit directory from the running user manager, not from $HOME. The install wrote a unit the manager never reads, then failed on 'systemctl --user enable' with 'Unit file opencodex-proxy.service does not exist'. Verified on the host: the same install against the real home succeeds and lists as enabled. That is exactly why these rows are disposable-host-only. A globally addressed service cannot be redirected into a temp root, so the safety property does not come from isolation - it comes from the sentinel plus the empty-registration gate on both sides of every row. --- .../codex-service-composed-acceptance.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index ba526530db..b3667847a0 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -128,18 +128,32 @@ function coordinatorPath(codexHome: string): string { class Fixture { readonly root = mkdtempSync(join(tmpdir(), "ocx-service-composed-")); - readonly home = join(this.root, "home"); + /** + * The REAL account home, deliberately. + * + * Every other path here is a fixture path, and a fake HOME was the obvious symmetry — but + * `systemctl --user` resolves its unit directory from the running user manager, not from + * `$HOME`. With a fake home, `ocx service install` wrote a unit file the user manager never + * reads and then failed on `systemctl --user enable`: "Unit file opencodex-proxy.service does + * not exist". Verified directly on the host — the same install against the real home succeeds + * and lists as `enabled`. + * + * That is precisely why these rows are disposable-host-only. A globally addressed service + * cannot be redirected into a temp root, so the safety property cannot come from isolation; + * it comes from the sentinel plus the empty-registration gate on both sides of every row. + */ + readonly home = homedir(); readonly userprofile = join(this.root, "userprofile"); readonly codex = join(this.root, "codex"); readonly ocx = join(this.root, "ocx"); readonly runtime = `/run/user/${process.getuid!()}`; - readonly unit = join(this.home, ".config/systemd/user", UNIT); + readonly unit = accountUnit; readonly lock: string; readonly lockAllowlist: string[]; readonly baselineOutside: Record; constructor(readonly row: RowId) { - for (const path of [this.home, this.userprofile, this.codex, this.ocx]) mkdirSync(path, { recursive: true, mode: 0o700 }); + for (const path of [this.userprofile, this.codex, this.ocx]) mkdirSync(path, { recursive: true, mode: 0o700 }); writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); writeFileSync(join(this.ocx, "config.json"), JSON.stringify({ port: 0, From 4ef102ba559a284f43eae77624a562424d1898e6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:19:10 +0900 Subject: [PATCH 05/12] fix(acceptance): provenance is optional at record v1 The row required a provenance entry for the admitted transaction, which no production path can satisfy: updateIntegrationRecord has NO caller in src/, and convergence-types.ts says outright that provenance is optional at v1 and that a record written before WP12 is valid. So the assertion was testing an unimplemented writer, not the service rows. It now fails only on DISAGREEMENT - a ledger that exists, has entries, and does not contain the transaction just admitted is a real defect. Absence is reported and allowed. That the ledger is never written is a genuine finding and is recorded in the PR rather than hidden by a green row. --- .../codex-service-composed-acceptance.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index b3667847a0..dd06769ffa 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -301,11 +301,23 @@ async function runRow(row: RowId): Promise { // native removal transaction succeeds. Requiring its record to survive would // contradict the production command's contract. if (existsSync(fx.ocx)) fail(`${row}: full uninstall left owned OpenCodex state behind`); - } else { - if (!existsSync(recordPath)) fail(`${row}: integration record is missing`); + } else if (existsSync(recordPath)) { + // Provenance is OPTIONAL at record v1 (convergence-types.ts: "Provenance is OPTIONAL at + // v1. A record written before WP12 is valid"), so its ABSENCE is not a row failure. What + // must hold is that when a ledger exists it agrees with the transaction we just admitted; + // a ledger that disagrees is a real defect and fails the row. const record = JSON.parse(readFileSync(recordPath, "utf8")) as { provenance?: { entries?: Array<{ txId?: string }> } }; - const matching = record.provenance?.entries?.filter(entry => entry.txId === after.currentTxId).length ?? 0; - if (matching === 0) fail(`${row}: admitted transaction has no provenance entry`); + const entries = record.provenance?.entries; + if (entries && entries.length > 0) { + if (!entries.some(entry => entry.txId === after.currentTxId)) { + fail(`${row}: integration record has provenance entries but none for the admitted transaction ${after.currentTxId}`); + } + console.log(`${row} PROVENANCE matched tx=${after.currentTxId}`); + } else { + console.log(`${row} PROVENANCE absent (optional at record v1; no production writer calls updateIntegrationRecord)`); + } + } else { + console.log(`${row} PROVENANCE record absent (optional at record v1)`); } console.log(`${row} PASS generation=${before.nativeGeneration}->${after.nativeGeneration} direction=${direction} tx=${after.currentTxId}`); console.log(`${row} OUTPUT ${output.stdout.trim().replace(/\s+/g, " ").slice(0, 500)}`); From 5116ed171978c9917813d9b4e348d434f33ce093 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:22:53 +0900 Subject: [PATCH 06/12] fix(uninstall): claim the admin token and per-home catalog backups ocx uninstall could not remove a config home OpenCodex created itself. It reported 'partial uninstall: unowned files remain' and left the whole directory behind, because two of our OWN writers produce files the ownership manifest never claimed: admin-api-token (lib/admin-secrets.ts) and the per-CODEX_HOME catalog-backup-<16 hex>.json (catalog/parsing.ts catalogBackupPathFor). Found by running the #1048 disposable-host service acceptance for real on a systemd host - row P10 invokes the production uninstall, which no workstation test can do. The hashed backup cannot be a literal manifest entry: its name depends on which Codex home it mirrors. It is swept by its exact generated shape instead, narrow enough that a user file cannot collide - pinned by a lookalike test that proves catalog-backup-notahash.json still survives. Falsified: reverting either hunk alone reddens the new removal test. --- src/lib/config-ownership.ts | 20 ++++++++++ tests/config-ownership-uninstall.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 97de40a9fa..8500b47f7d 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -40,6 +40,7 @@ const INITIAL_OWNED_PATHS = [ "artifacts", "auth.json", "auth.store.lock", + "admin-api-token", "catalog-backup.json", "claude-env.sh", "codex-accounts.json", @@ -333,6 +334,25 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { } } + // Per-catalog backups are named `catalog-backup-<16 hex>.json` (catalogBackupPathFor), one per + // CODEX_HOME, so they cannot be enumerated as literal manifest entries the way every other + // owned file can. Without this, `ocx uninstall` always reported "unowned files remain" and + // refused to remove a home OpenCodex created itself — the file is unambiguously ours, produced + // by our own writer, and the strict hex shape keeps the match from widening. + for (const name of readdirSync(configDir)) { + if (!/^catalog-backup-[0-9a-f]{16}\.json$/.test(name)) continue; + const path = join(configDir, name); + try { + removeOwnedEntry(rootPath, path); + } catch (error) { + return { + status: "partial", + reason: `could not remove owned path ${name}: ${error instanceof Error ? error.message : String(error)}`, + residualPaths: [path], + }; + } + } + try { unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST)); unlinkSync(join(configDir, CONFIG_OWNER_FILE)); diff --git a/tests/config-ownership-uninstall.test.ts b/tests/config-ownership-uninstall.test.ts index dca1ca6779..da2996864f 100644 --- a/tests/config-ownership-uninstall.test.ts +++ b/tests/config-ownership-uninstall.test.ts @@ -205,4 +205,55 @@ describe("owned config uninstall", () => { rmSync(parent, { recursive: true, force: true }); } }); + + /** + * The uninstall path could not remove a home OpenCodex created itself (#1048). + * + * Found by running the disposable-host service acceptance for real: `ocx uninstall` reported + * "partial uninstall: unowned files remain" and left the whole config directory behind. Two of + * our OWN writers produce files the manifest never claimed — + * `admin-api-token` (lib/admin-secrets.ts) and the per-CODEX_HOME + * `catalog-backup-<16 hex>.json` (catalog/parsing.ts `catalogBackupPathFor`). + * + * The hashed backup is the interesting one: its name depends on which Codex home it mirrors, + * so it cannot be a literal manifest entry the way every other owned file is. It is matched by + * its exact shape instead — narrow enough that a user's own file cannot collide, which is what + * keeps the "never delete what we do not own" guarantee intact. + */ + test("uninstall removes the admin token and per-home catalog backups it wrote itself", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-self-written-")); + const dir = join(parent, "config"); + + try { + // Establish ownership the way production does: the first owned write into an empty dir. + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(join(dir, "config.json"), "{}\n"); + writeFileSync(join(dir, "admin-api-token"), "token\n"); + writeFileSync(join(dir, "catalog-backup-0123456789abcdef.json"), "{}\n"); + + const result = removeOwnedConfigState(dir); + expect(result).toMatchObject({ status: "removed" }); + expect(existsSync(dir)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("a lookalike that is not our generated backup name is still never removed", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-lookalike-")); + const dir = join(parent, "config"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + // Not our shape: the digest segment is the wrong length, so this is a user file. + const foreign = join(dir, "catalog-backup-notahash.json"); + writeFileSync(foreign, "mine\n"); + + const result = removeOwnedConfigState(dir); + expect(result.status).toBe("partial"); + expect(readFileSync(foreign, "utf8")).toBe("mine\n"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); }); From 91d7b70ad7f093f7599c022aabced9fc75584063 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:23:37 +0900 Subject: [PATCH 07/12] fix(acceptance): P10 removes its own home, so do not re-invoke the CLI ocx uninstall deletes its OPENCODEX_HOME on success. Teardown then ran 'service uninstall' against a home that no longer exists and failed. The empty-registration gate still runs, which is what actually proves the host was restored. --- .../codex-service-composed-acceptance.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index dd06769ffa..10b759c449 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -260,6 +260,16 @@ class Fixture { } async teardown(): Promise { + // P10 is `ocx uninstall`: on success it removes its own OPENCODEX_HOME, so there is nothing + // left to uninstall and invoking the CLI again would fail on a home that no longer exists. + // The gate below still runs, which is what actually proves the host was restored. + if (!existsSync(this.ocx)) { + await emptyRegistrationGate(this.unit); + for (const path of this.lockAllowlist) if (existsSync(path)) unlinkSync(path); + sameManifest(this.outsideManifest(), this.baselineOutside, `${this.row}: outside-temp-root`); + rmSync(this.root, { recursive: true, force: true }); + return; + } const cleanup = await spawnResult([process.execPath, cliPath, "service", "uninstall"], { cwd: this.root, env: this.env() }); if (cleanup.exitCode !== 0 && existsSync(this.unit)) { fail(`${this.row}: fixture service teardown failed\n${cleanup.stderr}\n${cleanup.stdout}`); @@ -267,7 +277,7 @@ class Fixture { await emptyRegistrationGate(this.unit); for (const path of this.lockAllowlist) if (existsSync(path)) unlinkSync(path); sameManifest(this.outsideManifest(), this.baselineOutside, `${this.row}: outside-temp-root`); - rmSync(this.root, { recursive: true }); + rmSync(this.root, { recursive: true, force: true }); } } From 721690986d3d902f0facfbc38cbae1fc7f8657bf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:24:31 +0900 Subject: [PATCH 08/12] fix(acceptance): never fail a row with an empty message --- .../disposable-host/codex-service-composed-acceptance.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index 10b759c449..6ae3043992 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -204,7 +204,12 @@ class Fixture { async cli(args: string[]): Promise { const result = await spawnResult([process.execPath, cliPath, ...args], { cwd: this.root, env: this.env() }); - if (result.exitCode !== 0) fail(`${this.row}: ocx ${args.join(" ")} failed (${result.exitCode})\n${result.stderr}\n${result.stdout}`); + if (result.exitCode !== 0) { + // Both streams, always, and never an empty message: a row that fails with a blank error + // tells the operator nothing, and this runner only ever runs where reproducing is costly. + const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n") || "(no output on either stream)"; + fail(`${this.row}: ocx ${args.join(" ")} failed (exit ${result.exitCode})\n${detail}`); + } return result; } From 4c2ea588464383e60d7e4b43a2c46cddae07dabd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:25:06 +0900 Subject: [PATCH 09/12] fix(acceptance): print the failure message, not just the stack --- .../disposable-host/codex-service-composed-acceptance.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index 6ae3043992..d91702d6ba 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -354,6 +354,10 @@ async function main(): Promise { } main().catch(error => { - console.error(`FAIL disposable service acceptance: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + // message FIRST and unconditionally: Bun's `stack` renders a multi-line message as a bare + // "Error", which hid the actual cause of every failing row. + const message = error instanceof Error ? error.message : String(error); + console.error(`FAIL disposable service acceptance: ${message}`); + if (error instanceof Error && error.stack) console.error(error.stack); process.exitCode = 1; }); From fd142018dffcd6c747a4e54d83baf52f695ab0f3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:25:57 +0900 Subject: [PATCH 10/12] fix(acceptance): let the product create its own OpenCodex home Ownership is established by the first owned write into an EMPTY directory (config-ownership.ts createOwnership returns null for a non-empty root). The fixture pre-seeded config.json, so OpenCodex never claimed the home and 'ocx uninstall' correctly refused to delete a directory it could not prove it owned. P10 was failing on the fixture's shortcut, not on the production command. The routing seed is now merged in after 'service install' has created and claimed the home. --- .../codex-service-composed-acceptance.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index d91702d6ba..d6a4e8f40e 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -151,11 +151,22 @@ class Fixture { readonly lock: string; readonly lockAllowlist: string[]; readonly baselineOutside: Record; + readonly seed: Record; constructor(readonly row: RowId) { for (const path of [this.userprofile, this.codex, this.ocx]) mkdirSync(path, { recursive: true, mode: 0o700 }); writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); - writeFileSync(join(this.ocx, "config.json"), JSON.stringify({ + // The OpenCodex home is left EMPTY on purpose. + // + // Ownership is established by the first owned write into an empty directory + // (lib/config-ownership.ts `createOwnership`, which returns null for a non-empty root). + // Pre-seeding config.json means OpenCodex never claims the home, and `ocx uninstall` + // then correctly refuses to delete a directory it cannot prove it owns — so P10 was + // failing on the fixture's own shortcut rather than on the production command. + // + // Writing the seed AFTER the first CLI invocation would work too, but letting the product + // create its own home is closer to what P10 actually claims to accept. + this.seed = { port: 0, hostname: "127.0.0.1", syncResumeHistory: false, @@ -172,7 +183,7 @@ class Fixture { }, }, defaultProvider: "fixture", - }, null, 2)); + }; this.lock = coordinatorPath(this.codex); this.lockAllowlist = [this.lock, `${this.lock}-journal`, `${this.lock}-wal`, `${this.lock}-shm`]; for (const path of this.lockAllowlist) if (existsSync(path)) fail(`${row}: pre-existing coordinator artifact: ${path}`); @@ -232,6 +243,13 @@ class Fixture { async install(): Promise { await this.cli(["service", "install"]); if (!existsSync(this.unit)) fail(`${this.row}: install did not create fixture unit`); + // Now that the product owns the home, apply the fixture's routing seed. `service install` + // wrote a default config.json, so merge rather than replace. + const configPath = join(this.ocx, "config.json"); + const current = existsSync(configPath) + ? JSON.parse(readFileSync(configPath, "utf8")) as Record + : {}; + writeFileSync(configPath, `${JSON.stringify({ ...current, ...this.seed }, null, 2)}\n`, { mode: 0o600 }); } async waitForRuntime(): Promise<{ port: number; pid: number }> { From f068bfe3ec5a057d9595bba27896718e03b10bec Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:26:29 +0900 Subject: [PATCH 11/12] fix(acceptance): P18 authenticates with the token the service minted --- .../disposable-host/codex-service-composed-acceptance.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index d6a4e8f40e..da6d331636 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -266,7 +266,14 @@ class Fixture { async apiStop(): Promise { const runtime = await this.waitForRuntime(); - const script = `const r=await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)},{method:"POST",headers:{"x-opencodex-api-key":"disposable-admin-token"}});console.log(r.status,await r.text());if(!r.ok)process.exit(1)`; + // Read the token the SERVICE actually minted. The env var only reaches a process this + // script launches; the service runs under systemd with its own environment, and + // `configuredAdminToken` falls back to `admin-api-token` in the config home. Passing the + // env value here produced a 401 against a server that had generated a different token. + const tokenPath = join(this.ocx, "admin-api-token"); + if (!existsSync(tokenPath)) fail(`${this.row}: service did not mint an admin token at ${tokenPath}`); + const token = readFileSync(tokenPath, "utf8").trim(); + const script = `const r=await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)},{method:"POST",headers:{"x-opencodex-api-key":${JSON.stringify(token)}}});console.log(r.status,await r.text());if(!r.ok)process.exit(1)`; const result = await spawnResult([process.execPath, "--eval", script], { cwd: this.root, env: this.env() }); if (result.exitCode !== 0) fail(`${this.row}: POST /api/stop failed\n${result.stderr}\n${result.stdout}`); return result; From eadf9210b417fb97f3964eeaf516927cbded58f5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 05:27:11 +0900 Subject: [PATCH 12/12] fix(acceptance): a drained connection is a valid POST /api/stop outcome /api/stop stops the service and drains the process serving the request, so the socket can close before a response is flushed - the more completely the endpoint does its job, the likelier that is. A 4xx still fails the row, and the authoritative oracle remains the +1 remove transaction. --- .../codex-service-composed-acceptance.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts index da6d331636..9565992974 100644 --- a/scripts/disposable-host/codex-service-composed-acceptance.ts +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -273,7 +273,21 @@ class Fixture { const tokenPath = join(this.ocx, "admin-api-token"); if (!existsSync(tokenPath)) fail(`${this.row}: service did not mint an admin token at ${tokenPath}`); const token = readFileSync(tokenPath, "utf8").trim(); - const script = `const r=await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)},{method:"POST",headers:{"x-opencodex-api-key":${JSON.stringify(token)}}});console.log(r.status,await r.text());if(!r.ok)process.exit(1)`; + // A reset is an ACCEPTABLE outcome here, not a failure. `POST /api/stop` stops the service + // and drains this very process, so the socket can close before a response is flushed — the + // more thoroughly the endpoint does its job, the likelier that is. A 401/4xx still fails. + // The authoritative oracle is the +1 remove transaction the caller asserts either way. + const script = [ + `try {`, + ` const r = await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)}, { method: "POST", headers: { "x-opencodex-api-key": ${JSON.stringify(token)} } });`, + ` console.log(r.status, await r.text());`, + ` if (!r.ok) process.exit(1);`, + `} catch (error) {`, + ` const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";`, + ` if (code !== "ECONNRESET" && code !== "ConnectionClosed") { console.error(String(error)); process.exit(1); }`, + ` console.log("stop-closed-connection", code);`, + `}`, + ].join("\n"); const result = await spawnResult([process.execPath, "--eval", script], { cwd: this.root, env: this.env() }); if (result.exitCode !== 0) fail(`${this.row}: POST /api/stop failed\n${result.stderr}\n${result.stdout}`); return result;