From 5befc548afb3b29ff5fb08eb9080992df6407584 Mon Sep 17 00:00:00 2001 From: Sungyong Cho Date: Tue, 6 Oct 2026 13:27:54 +0200 Subject: [PATCH 1/2] fix(codex-shim): name the path and chmod fix when a private shim artifact is group-writable --- .../src/content/docs/reference/cli/lifecycle.md | 4 ++++ src/codex/shim-overlay.ts | 10 +++++++++- .../codex-shim-migration.test.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 2115a9c203e..4cca73d7470 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -879,6 +879,10 @@ Migration restores the recorded native launcher without replacing a newer launch then installs the private wrapper. If private installation fails after native restoration, the native launcher stays restored and the operation can be retried. Missing or unusable native launchers require package-manager repair; OpenCodex does not guess another installation or rewrap the manager's path. +Migration also refuses when the recorded `codex-shim.json` is not a regular file owned by you or is +group- or world-writable. Older releases wrote that file with the process umask, so a umask of `002` +left it at mode `0664`. The refusal names the file and the `chmod 600` that clears it; OpenCodex does +not change the permissions itself. Launcher installation alone does not prove that Codex requests will use OpenCodex. After a runnable install, the command checks the current Codex routing and reports a warning instead of a green result diff --git a/src/codex/shim-overlay.ts b/src/codex/shim-overlay.ts index c35187fd937..22f70d3adca 100644 --- a/src/codex/shim-overlay.ts +++ b/src/codex/shim-overlay.ts @@ -48,11 +48,19 @@ function lexical(path: string): Stats | null { try { return lstatSync(path); } catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; } } +/** Name the path and the failed check; a group/world-writable owned file gets the chmod that fixes it. */ +function untrustedArtifact(path: string, stat: Stats): Error { + const prefix = `Private Codex artifact is not an owned regular file: ${path}`; + if (stat.isSymbolicLink() || !stat.isFile()) return new Error(`${prefix} is not a regular file`); + if (stat.uid !== process.getuid?.()) return new Error(`${prefix} is owned by uid ${stat.uid}`); + const mode = (stat.mode & 0o777).toString(8).padStart(4, "0"); + return new Error(`${prefix} is group- or world-writable (mode ${mode}); run chmod 600 ${shQuote(path)} and retry`); +} function snapshot(path: string): Snapshot | null { const before = lexical(path); if (!before) return null; if (!before.isFile() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o022)) { - throw new Error("Private Codex artifact is not an owned regular file"); + throw untrustedArtifact(path, before); } const bounded = readBoundedRegularFile(path, PRIVATE_FILE_MAX_BYTES); if (!bounded || "warning" in bounded) throw new Error("Private Codex artifact is unreadable or changed"); diff --git a/tests/codex-integration/codex-shim-migration.test.ts b/tests/codex-integration/codex-shim-migration.test.ts index 93bfeecbf9e..15f46e7a1eb 100644 --- a/tests/codex-integration/codex-shim-migration.test.ts +++ b/tests/codex-integration/codex-shim-migration.test.ts @@ -80,6 +80,22 @@ describe.skipIf(process.platform === "win32")("legacy Unix shim migration", () = expect(installCodexShim()).toMatchObject({ installed: false, runnable: true }); })); + test("group-writable legacy state (umask 002) refuses with the path and the chmod that unblocks migration", () => fixture(f => { + fs.chmodSync(f.statePath, 0o664); + const raw = fs.readFileSync(f.statePath, "utf8"); + const refused = installCodexShim(); + expect(refused).toMatchObject({ installed: false, refused: true }); + expect(refused.message).toContain(`Private Codex artifact is not an owned regular file: ${f.statePath}`); + expect(refused.message).toContain("group- or world-writable (mode 0664)"); + expect(refused.message).toContain(`run chmod 600 '${f.statePath}' and retry`); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(fs.readFileSync(f.native, "utf8")).toBe(f.legacyBytes); + fs.chmodSync(f.statePath, 0o600); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + })); + test("regular backup restoration retains its inode", () => fixture(f => { fs.unlinkSync(f.backup); fs.writeFileSync(f.backup, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); From 159d4f7704ba5138a5fbafc98eaec82e02dca231 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:35:49 +0900 Subject: [PATCH 2/2] test(codex): cover quoted legacy shim chmod recovery --- .../content/docs/reference/cli/lifecycle.md | 5 ++-- structure/runtime.md | 2 +- .../codex-shim-migration.test.ts | 23 +++++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 4cca73d7470..7baa048e2ab 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -881,8 +881,9 @@ launcher stays restored and the operation can be retried. Missing or unusable na package-manager repair; OpenCodex does not guess another installation or rewrap the manager's path. Migration also refuses when the recorded `codex-shim.json` is not a regular file owned by you or is group- or world-writable. Older releases wrote that file with the process umask, so a umask of `002` -left it at mode `0664`. The refusal names the file and the `chmod 600` that clears it; OpenCodex does -not change the permissions itself. +left it at mode `0664`. The refusal names the file and the `chmod 600` that clears only the +group- or world-writable refusal. Symlink and foreign-owner refusals remain; OpenCodex does not +change the permissions itself. Launcher installation alone does not prove that Codex requests will use OpenCodex. After a runnable install, the command checks the current Codex routing and reports a warning instead of a green result diff --git a/structure/runtime.md b/structure/runtime.md index a4ba9577f82..5c365827254 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -143,7 +143,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/cli/tray-proxy.ts` | Proxy restart coordination. Replacement re-observation captures one clock value for both its remaining budget and absolute end, so the observation window never extends the caller deadline. CLI proxy update restart: `src/cli/system-restart-client.ts` preserves a private newer-CLI candidate with its original runtime record and physical home snapshot. `src/cli/update-restart.ts` accepts only known versions and positively classified standalone POSIX targets: PID-1 parent, no installed/active manager, no ownership record, no client/sibling role. It holds the existing ownership mutation lease through stop and spawn and revalidates the captured target before stop. `src/cli/update-restart-transport.ts` proves identity and sends stop on one direct TCP connection, refuses reconnects and requires complete successful teardown acknowledgement. It never signals a PID or reclaims a port. Confirmed predecessor exit, runtime-record removal, port availability and definitive endpoint absence precede the one launch. The executable that launch spawns must pass the shared `REAL_BUN_MIN_BYTES` gate: an incomplete runtime refuses before any stop byte, and after a confirmed stop the transaction waits for it within the deadline, then rechecks deadline and physical home synchronously before spawning. `src/cli/update-restart-child.ts` strips inherited lease delegation, acquires its own lease before CLI preflight and checks the frozen physical homes, ownership revision, CLI version, endpoint and deadline before startup, each bind and publication. It keeps custody through rollback; its deadline ends an unfinished child. `src/cli/update-restart-home.ts` owns physical-home and unclaimed-state checks. Success requires that launched child PID, fresh attestation and the exact known version; all incomplete outcomes terminate without generic restart recovery. Supervised, Windows and uncertain cases refuse this update path. | -| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration; `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | +| `src/codex/shim.ts` | Codex autostart facade: dispatches Unix private PATH overlays to `src/codex/shim-overlay.ts` and retains Windows in-place script wrapping. `src/codex/shim-migration.ts` owns explicit legacy Unix restoration. Legacy Unix shim migration rejects symlink, foreign-owned, and group/world-writable state files. The writable-mode refusal prints a shell-quoted `chmod 600` hint; it clears only that mode refusal, leaving file-type and ownership checks intact. `tests/codex-integration/codex-shim-migration.test.ts` executes the hint through `/bin/sh` with a space and apostrophe in the state path, then verifies mode `0600` and schema-2 migration. `src/codex/shim-diagnostics.ts` owns diagnosis/status, distinguishes runnable from active overlays and states the unhealthy verdict for a damaged in-place (Windows) wrapper. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint; management subcommands bypass startup even after value-taking global flags. Discovery resolves fnm’s temporary `fnm_multishells` directory to the durable installation directory through `src/codex/shim-path-resolution.ts`, taking the directory realpath while retaining the command basename; unresolved temporary entries refuse installation. The injected path flavour governs join and separator. The shared PATH scanner follows npm/fnm symlinks and POSIX target execute access, skips non-executable and special-file entries, and reads only the first 16 KiB of regular files through a descriptor with nonblocking POSIX open and a post-open type check. An unreadable executable still shadows later commands. Overlay activation requires the selected executable to identify the recorded private wrapper or the same file through an alias; another marker-bearing shim does not prove activation. Shell functions/aliases and empty PATH components are outside this scanner’s guarantee. `src/cli/codex-shim-readiness.ts` retains connect’s advisory exit policy. `tests/codex-integration/codex-shim-path-readiness.test.ts` covers POSIX shell lookup parity, FIFO replacement, symlinks and bounded reads. Windows retains PATHEXT lookup without POSIX execute-access checks. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects diff --git a/tests/codex-integration/codex-shim-migration.test.ts b/tests/codex-integration/codex-shim-migration.test.ts index 15f46e7a1eb..f4fd449040c 100644 --- a/tests/codex-integration/codex-shim-migration.test.ts +++ b/tests/codex-integration/codex-shim-migration.test.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { autoRestoreCodexShim, buildUnixCodexShim, codexShimStatus, diagnoseCodexShim, installCodexShim, setCodexShimProbeHookForTests, setCodexShimProbeObservationMsForTests, uninstallCodexShim } from "../../src/codex/shim"; import { readState, type ShimState } from "../../src/codex/shim-state-file"; -function fixture(run: (f: { root: string; home: string; native: string; backup: string; quarantine: string; wrapper: string; statePath: string; state: ShimState; legacyBytes: string; version: (name: string) => string }) => void): void { +function fixture(run: (f: { root: string; home: string; native: string; backup: string; quarantine: string; wrapper: string; statePath: string; state: ShimState; legacyBytes: string; version: (name: string) => string }) => void, homeName = "home"): void { const root = fs.mkdtempSync(join(tmpdir(), "ocx-overlay-migration-")); - const home = join(root, "home"); + const home = join(root, homeName); const bin = join(root, "manager"); fs.mkdirSync(home, { mode: 0o700 }); fs.mkdirSync(bin); @@ -96,6 +96,25 @@ describe.skipIf(process.platform === "win32")("legacy Unix shim migration", () = expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); })); + test("printed chmod hint handles spaces and apostrophes and permits legacy migration", () => fixture(f => { + fs.chmodSync(f.statePath, 0o664); + const raw = fs.readFileSync(f.statePath, "utf8"); + const refused = installCodexShim(); + expect(refused).toMatchObject({ installed: false, refused: true }); + expect(refused.message).toContain(`run chmod 600 '${f.root}/owner'\\''s home/codex-shim.json' and retry`); + expect(fs.readFileSync(f.statePath, "utf8")).toBe(raw); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o664); + const hint = refused.message.match(/run (chmod 600 .+) and retry/)?.[1]; + expect(hint).toBeDefined(); + const result = Bun.spawnSync(["/bin/sh", "-c", hint!]); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o600); + const installed = installCodexShim(); + expect(installed.installed, installed.message).toBe(true); + expect(readState()).toMatchObject({ schema: 2, launcherPath: f.native }); + expect(fs.lstatSync(f.statePath).mode & 0o777).toBe(0o600); + }, "owner's home")); + test("regular backup restoration retains its inode", () => fixture(f => { fs.unlinkSync(f.backup); fs.writeFileSync(f.backup, "#!/bin/sh\nexit 0\n", { mode: 0o755 });