From 5fce6bd6fa5c586e66c8f757912056e87acdec61 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 03:00:50 +0200 Subject: [PATCH 01/17] feat: add deterministic remote client artifacts --- docs/client-artifact.md | 47 +++++++ package.json | 1 + scripts/build-client-artifact.ts | 200 +++++++++++++++++++++++++++++ tests/client-artifact.test.ts | 213 +++++++++++++++++++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 docs/client-artifact.md create mode 100644 scripts/build-client-artifact.ts create mode 100644 tests/client-artifact.test.ts diff --git a/docs/client-artifact.md b/docs/client-artifact.md new file mode 100644 index 00000000..7dab4cf3 --- /dev/null +++ b/docs/client-artifact.md @@ -0,0 +1,47 @@ +# Remote client artifacts + +Build a versioned client candidate from a clean runtime checkout: + +```sh +bun install --frozen-lockfile +bun run build:client --output /path/to/client-artifacts/new-candidate +bun /path/to/client-artifacts/new-candidate/src/cli/index.js --version +``` + +To rebuild an older pinned source revision with the current reviewed builder, +pass its clean checkout explicitly: + +```sh +bun run build:client --output /path/to/client-artifacts/new-candidate \ + --source-root /path/to/clean-pinned-checkout +``` + +The destination must not exist. This command never activates a candidate, +changes a `current` link, reads home configuration or starts a proxy. Runtime +source and lockfile changes must be committed first. Source provenance is the +checkout HEAD; package metadata comes from that Git revision, while the +manifest separately records the builder digest and Bun version. + +The artifact preserves the remote launcher's contract: `src/cli/index.js`, +`package.json`, `source-sha` and `index.js.sha256`. It also ships the executable +`bin/codex.ocx-client` shim for an operator-controlled install step. The shim +always selects `OCX_CLIENT_CODEX_HOME` or its isolated default `~/.codex-ocx`; +it ignores an inherited `CODEX_HOME` and explicitly refuses the native +`~/.codex` home. `OCX_CLIENT_OCX_BIN` and `OCX_CLIENT_CODEX_BIN` can select the +governed remote launcher and real Codex executable during installation. + +The CLI and its imported dependencies and upstream model snapshot are bundled; +package metadata remains alongside the bundle for version reporting. This is +not a GUI, tray, service or storage-worker distribution. Explicit local +lifecycle commands fail closed even when the bundle is invoked directly. +Continue using the existing governed remote launcher for remote connectivity +and command authorization; it prevents indirect lifecycle paths too. Building +a candidate does not replace a globally installed shim or modify either the +isolated or native Codex home. + +Before activation, review the exact source revision, run the catalog sync +regressions and harmless version probe, and compare the bundle checksum against +`index.js.sha256`. `artifact-manifest.json` additionally binds the package, +lockfile, builder and Bun version. Artifact integrity does not prove provider +availability or healthy remote deployment. Activation and rollback of the +launcher-managed `current` link are separate operator actions. diff --git a/package.json b/package.json index 6ffc4293..1782172f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts", "build:gui": "cd gui && bun install && bun run build && cd .. && bun run prepare:package", "prepare:package": "bun scripts/prepare-package.ts", + "build:client": "bun scripts/build-client-artifact.ts", "prepack": "bun run prepare:package", "prepare": "husky || true", "prepublishOnly": "bun run typecheck && bun run build:gui", diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts new file mode 100644 index 00000000..cf6d8234 --- /dev/null +++ b/scripts/build-client-artifact.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const sha256 = (data: string | Uint8Array) => + createHash("sha256").update(data).digest("hex"); + +function git(root: string, ...args: string[]): string { + const result = Bun.spawnSync(["git", ...args], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + if (!result.success) throw new Error(`git ${args[0]} failed`); + return result.stdout.toString().trim(); +} + +// The remote wrapper intercepts lifecycle commands; reject direct bundle use too. +export const CLIENT_GUARD = ` +const clientCommand = process.argv[2] || ""; +if (["start", "stop", "restart", "ensure", "service", "gui", "init", "setup", "uninstall", "remove", "update", "tray", "restore", "eject", "proxy", "daemon"].includes(clientCommand) || clientCommand.startsWith("__")) { + console.error("OCX client artifact: local lifecycle commands are disabled; use the remote launcher."); + process.exit(64); +} +`; + +export const CODEX_CLIENT_SHIM = [ + "#!/usr/bin/env sh", + "# OpenCodex client-only Codex shim. The proxy remains remote; this only selects an isolated Codex home.", + "set -eu", + 'home_dir="${HOME:?HOME is required}"', + 'native_home="${home_dir%/}/.codex"', + 'client_home="${OCX_CLIENT_CODEX_HOME:-${home_dir%/}/.codex-ocx}"', + 'client_home="${client_home%/}"', + 'if [ "$client_home" = "$native_home" ]; then', + ' echo "OCX client-only: refusing native Codex home $native_home" >&2', + " exit 78", + "fi", + 'export CODEX_HOME="$client_home"', + 'token_file="${OCX_CLIENT_TOKEN_FILE:-${home_dir%/}/.opencodex/service-api-token}"', + 'if [ -z "${OPENCODEX_API_KEY:-}" ] && [ -f "$token_file" ]; then', + ' OPENCODEX_API_KEY="$(cat "$token_file")"', + " export OPENCODEX_API_KEY", + "fi", + 'if [ -z "${OPENCODEX_API_AUTH_TOKEN:-}" ] && [ -n "${OPENCODEX_API_KEY:-}" ]; then', + ' OPENCODEX_API_AUTH_TOKEN="$OPENCODEX_API_KEY"', + " export OPENCODEX_API_AUTH_TOKEN", + "fi", + 'ocx_bin="${OCX_CLIENT_OCX_BIN:-${home_dir%/}/.local/bin/ocx}"', + 'codex_bin="${OCX_CLIENT_CODEX_BIN:-${home_dir%/}/.local/bin/codex.opencodex-real}"', + 'case "${1:-}" in', + " agents|app-server|apply|cloud|completion|doctor|exec-server|features|help|login|logout|mcp-server|plugin|remote-control|update|--help|-h|--version|-V|debug) ;;", + " *)", + ' "$ocx_bin" ensure >/dev/null 2>&1 || {', + ' echo "Codex: central OCX proxy unavailable through the governed remote launcher" >&2', + " exit 69", + " }", + " ;;", + "esac", + 'exec "$codex_bin" "$@"', + "", +].join("\n"); + +export async function buildClientArtifact(destination: string, root = ROOT) { + const output = resolve(destination); + if (existsSync(output)) + throw new Error( + "Destination already exists; build a new candidate instead", + ); + // Build only clean, tracked runtime inputs. Tooling/docs edits do not invalidate + // the runtime revision; package metadata is read from the exact Git object. + const dirty = git( + root, + "status", + "--porcelain", + "--untracked-files=all", + "--", + "src", + "bun.lock", + ); + if (dirty) + throw new Error( + "Runtime inputs are dirty; commit or isolate them before building", + ); + const sourceSha = git(root, "rev-parse", "HEAD"); + const packageText = git(root, "show", `${sourceSha}:package.json`) + "\n"; + const lock = readFileSync(join(root, "bun.lock")); + mkdirSync(dirname(output), { recursive: true }); + const staging = mkdtempSync(join(dirname(output), ".ocx-client-build-")); + try { + const result = await Bun.build({ + entrypoints: [join(root, "src/cli/index.ts")], + target: "bun", + format: "esm", + packages: "bundle", + splitting: false, + sourcemap: "none", + banner: CLIENT_GUARD, + }); + if (!result.success) + throw new Error( + `Client bundle failed: ${result.logs.map(String).join("\n")}`, + ); + if (result.outputs.length !== 1) + throw new Error( + "Unexpected bundle assets; extend the artifact manifest before shipping", + ); + const bundle = new Uint8Array(await result.outputs[0]!.arrayBuffer()); + const digest = sha256(bundle); + mkdirSync(join(staging, "src/cli"), { recursive: true }); + writeFileSync(join(staging, "src/cli/index.js"), bundle); + mkdirSync(join(staging, "bin")); + writeFileSync(join(staging, "bin/codex.ocx-client"), CODEX_CLIENT_SHIM, { + mode: 0o755, + }); + chmodSync(join(staging, "bin/codex.ocx-client"), 0o755); + // Package metadata is read relative to src/cli/index.js by the CLI. + // The upstream model JSON and imported dependencies are bundled by Bun. + writeFileSync(join(staging, "package.json"), packageText); + writeFileSync(join(staging, "source-sha"), sourceSha + "\n"); + writeFileSync( + join(staging, "index.js.sha256"), + `${digest} src/cli/index.js\n`, + ); + const manifest = { + format: "ocx-remote-client-v1", + sourceSha, + bunVersion: Bun.version, + lockSha256: sha256(lock), + builderSha256: sha256(readFileSync(fileURLToPath(import.meta.url))), + files: { + "src/cli/index.js": digest, + "bin/codex.ocx-client": sha256(CODEX_CLIENT_SHIM), + "package.json": sha256(packageText), + }, + activation: "not-activated", + }; + writeFileSync( + join(staging, "artifact-manifest.json"), + JSON.stringify(manifest, null, 2) + "\n", + ); + // Never touch `current`; publication creates one new candidate directory. + if (existsSync(output)) + throw new Error( + "Destination appeared during build; refusing replacement", + ); + renameSync(staging, output); + return manifest; + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + const args = process.argv.slice(2); + const outputIndex = args.indexOf("--output"); + const sourceIndex = args.indexOf("--source-root"); + const output = outputIndex >= 0 ? args[outputIndex + 1] : undefined; + const sourceRoot = sourceIndex >= 0 ? args[sourceIndex + 1] : ROOT; + const knownArgs = new Set(["--output", "--source-root"]); + const flags = args.filter((arg) => arg.startsWith("--")); + const expectedLength = sourceIndex >= 0 ? 4 : 2; + if ( + !output || + !sourceRoot || + args.length !== expectedLength || + flags.some((flag) => !knownArgs.has(flag)) + ) { + console.error( + "Usage: bun run build:client --output [--source-root ]", + ); + process.exitCode = 2; + } else { + try { + console.log( + JSON.stringify( + await buildClientArtifact(output, resolve(sourceRoot)), + null, + 2, + ), + ); + } catch (error) { + console.error( + error instanceof Error ? error.message : "Client artifact build failed", + ); + process.exitCode = 1; + } + } +} diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts new file mode 100644 index 00000000..43a6e88f --- /dev/null +++ b/tests/client-artifact.test.ts @@ -0,0 +1,213 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildClientArtifact } from "../scripts/build-client-artifact"; + +const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); + +describe("remote client artifact", () => { + test("CLI builds from an explicit clean source checkout", () => { + const output = join(scratch, "explicit-source-candidate"); + const root = join(import.meta.dir, ".."); + const script = join(root, "scripts/build-client-artifact.ts"); + const result = Bun.spawnSync( + [process.execPath, script, "--output", output, "--source-root", root], + { cwd: scratch }, + ); + expect(result.exitCode).toBe(0); + const sourceSha = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: root }) + .stdout.toString() + .trim(); + expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( + sourceSha + "\n", + ); + }); + + test("builds a self-contained, SHA-bound candidate without activation", async () => { + const output = join(scratch, "candidate"); + const manifest = await buildClientArtifact(output); + const entry = join(output, "src/cli/index.js"); + const digest = createHash("sha256") + .update(readFileSync(entry)) + .digest("hex"); + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { + cwd: join(import.meta.dir, ".."), + }); + expect(result.success).toBe(true); + return result.stdout.toString().trim(); + }; + const sourceSha = git("rev-parse", "HEAD"); + const packageText = git("show", `${sourceSha}:package.json`) + "\n"; + const lock = readFileSync(join(import.meta.dir, "../bun.lock")); + const builder = readFileSync( + fileURLToPath( + new URL("../scripts/build-client-artifact.ts", import.meta.url), + ), + ); + expect(manifest.sourceSha).toBe(sourceSha); + expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( + manifest.sourceSha + "\n", + ); + expect(readFileSync(join(output, "index.js.sha256"), "utf8")).toBe( + `${digest} src/cli/index.js\n`, + ); + expect(manifest.files["src/cli/index.js"]).toBe(digest); + expect(manifest.files["package.json"]).toBe( + createHash("sha256").update(packageText).digest("hex"), + ); + expect(manifest.lockSha256).toBe( + createHash("sha256").update(lock).digest("hex"), + ); + expect(manifest.builderSha256).toBe( + createHash("sha256").update(builder).digest("hex"), + ); + expect(readFileSync(join(output, "package.json"), "utf8")).toBe( + packageText, + ); + const metadata = JSON.parse( + readFileSync(join(output, "package.json"), "utf8"), + ); + expect(existsSync(join(output, "node_modules"))).toBe(false); + expect(existsSync(join(scratch, "current"))).toBe(false); + const env = { + ...process.env, + OPENCODEX_HOME: join(scratch, "ocx-home"), + CODEX_HOME: join(scratch, "codex-home"), + }; + mkdirSync(env.OPENCODEX_HOME); + mkdirSync(env.CODEX_HOME); + const version = Bun.spawnSync([process.execPath, entry, "--version"], { + env, + cwd: scratch, + }); + expect(version.exitCode).toBe(0); + expect(version.stdout.toString()).toContain( + `opencodex ${metadata.version}`, + ); + expect(readFileSync(entry, "utf8")).toContain("syncExternalOcxCatalog"); + for (const command of [ + "start", + "ensure", + "service", + "init", + "__startup-health", + ]) { + const denied = Bun.spawnSync([process.execPath, entry, command], { + env, + cwd: scratch, + }); + expect(denied.exitCode).toBe(64); + expect(denied.stderr.toString()).toContain( + "local lifecycle commands are disabled", + ); + } + expect(existsSync(join(scratch, "ocx-home", "proxy.pid"))).toBe(false); + await expect(buildClientArtifact(output)).rejects.toThrow( + "Destination already exists", + ); + expect(createHash("sha256").update(readFileSync(entry)).digest("hex")).toBe( + digest, + ); + + const duplicate = join(scratch, "duplicate"); + await buildClientArtifact(duplicate); + expect(readFileSync(join(duplicate, "src/cli/index.js"))).toEqual( + readFileSync(entry), + ); + expect(readFileSync(join(duplicate, "artifact-manifest.json"))).toEqual( + readFileSync(join(output, "artifact-manifest.json")), + ); + }); + + test("ships a client shim that cannot select the native Codex home", async () => { + const output = join(scratch, "shim-candidate"); + const manifest = await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client"); + const home = join(scratch, "shim-home"); + const nativeHome = join(home, ".codex"); + const capture = join(scratch, "captured-home"); + const real = join(scratch, "codex-real"); + mkdirSync(nativeHome, { recursive: true }); + writeFileSync( + join(nativeHome, "config.toml"), + "direct Azure config stays untouched\n", + ); + writeFileSync( + real, + '#!/usr/bin/env sh\nprintf \'%s\\n\' "$CODEX_HOME" > "$OCX_CAPTURE"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: home, + CODEX_HOME: nativeHome, + OCX_CAPTURE: capture, + OCX_CLIENT_CODEX_BIN: real, + OCX_CLIENT_OCX_BIN: "/bin/false", + }; + + expect(statSync(shim).mode & 0o777).toBe(0o755); + expect(manifest.files["bin/codex.ocx-client"]).toBe( + createHash("sha256").update(readFileSync(shim)).digest("hex"), + ); + const defaultRun = Bun.spawnSync([shim, "--version"], { env }); + expect(defaultRun.exitCode).toBe(0); + expect(readFileSync(capture, "utf8")).toBe(join(home, ".codex-ocx") + "\n"); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); + + const selected = join(home, ".codex-client-test"); + const selectedRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: selected }, + }); + expect(selectedRun.exitCode).toBe(0); + expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + + const nativeRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: nativeHome }, + }); + expect(nativeRun.exitCode).toBe(78); + expect(nativeRun.stderr.toString()).toContain("refusing native Codex home"); + expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); + }); + + test("does not replace an existing destination symlink", async () => { + const existing = join(scratch, "existing"); + writeFileSync(existing, "preserve"); + const link = join(scratch, "alias"); + symlinkSync(existing, link); + await expect(buildClientArtifact(link)).rejects.toThrow( + "Destination already exists", + ); + expect(readFileSync(existing, "utf8")).toBe("preserve"); + }); + + test("rejects untracked runtime inputs before creating an artifact", async () => { + const fixture = mkdtempSync(join(scratch, "dirty-")); + expect(Bun.spawnSync(["git", "init", fixture]).success).toBe(true); + writeFileSync(join(fixture, "bun.lock"), "untracked input"); + const destination = join(scratch, "dirty-output"); + await expect(buildClientArtifact(destination, fixture)).rejects.toThrow( + "Runtime inputs are dirty", + ); + expect(existsSync(destination)).toBe(false); + }); +}); From f966530ed7a1d0671c41b2cc4287f9961997c2c6 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 03:14:15 +0200 Subject: [PATCH 02/17] fix: harden client artifact isolation --- docs/client-artifact.md | 18 ++++--- scripts/build-client-artifact.ts | 83 ++++++++++++++++++++++++++++++-- tests/client-artifact.test.ts | 47 +++++++++++++++--- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index 7dab4cf3..5930a779 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -24,16 +24,20 @@ manifest separately records the builder digest and Bun version. The artifact preserves the remote launcher's contract: `src/cli/index.js`, `package.json`, `source-sha` and `index.js.sha256`. It also ships the executable -`bin/codex.ocx-client` shim for an operator-controlled install step. The shim -always selects `OCX_CLIENT_CODEX_HOME` or its isolated default `~/.codex-ocx`; -it ignores an inherited `CODEX_HOME` and explicitly refuses the native -`~/.codex` home. `OCX_CLIENT_OCX_BIN` and `OCX_CLIENT_CODEX_BIN` can select the -governed remote launcher and real Codex executable during installation. +`bin/codex.ocx-client` POSIX shim and `bin/codex.ocx-client.ps1` PowerShell shim +for an operator-controlled install step. The shims always select +`OCX_CLIENT_CODEX_HOME` or its isolated default `~/.codex-ocx`; +they ignore an inherited `CODEX_HOME` and explicitly refuse the native +`~/.codex` home, including normalized aliases and symlinked paths. An explicit +`OCX_CLIENT_CODEX_HOME` must be absolute. `OCX_CLIENT_OCX_BIN` and +`OCX_CLIENT_CODEX_BIN` can select the governed remote launcher and real Codex +executable during installation. The CLI and its imported dependencies and upstream model snapshot are bundled; package metadata remains alongside the bundle for version reporting. This is -not a GUI, tray, service or storage-worker distribution. Explicit local -lifecycle commands fail closed even when the bundle is invoked directly. +not a GUI, tray, service or storage-worker distribution. Direct bundle use is +limited to read-only help, version, status, and health commands; every other +command fails closed and must go through the governed remote launcher. Continue using the existing governed remote launcher for remote connectivity and command authorization; it prevents indirect lifecycle paths too. Building a candidate does not replace a globally installed shim or modify either the diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index cf6d8234..53c20174 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -26,10 +26,12 @@ function git(root: string, ...args: string[]): string { return result.stdout.toString().trim(); } -// The remote wrapper intercepts lifecycle commands; reject direct bundle use too. +// The remote wrapper owns all mutation and lifecycle behavior. Direct bundle use +// is deliberately limited to a small read-only surface. export const CLIENT_GUARD = ` const clientCommand = process.argv[2] || ""; -if (["start", "stop", "restart", "ensure", "service", "gui", "init", "setup", "uninstall", "remove", "update", "tray", "restore", "eject", "proxy", "daemon"].includes(clientCommand) || clientCommand.startsWith("__")) { +const clientReadOnlyCommands = new Set(["", "help", "--help", "-h", "version", "--version", "-v", "status", "health"]); +if (!clientReadOnlyCommands.has(clientCommand)) { console.error("OCX client artifact: local lifecycle commands are disabled; use the remote launcher."); process.exit(64); } @@ -39,10 +41,39 @@ export const CODEX_CLIENT_SHIM = [ "#!/usr/bin/env sh", "# OpenCodex client-only Codex shim. The proxy remains remote; this only selects an isolated Codex home.", "set -eu", - 'home_dir="${HOME:?HOME is required}"', + 'home_dir="$(cd -P -- "${HOME:?HOME is required}" && pwd -P)" || {', + ' echo "OCX client-only: HOME must name an accessible directory" >&2', + " exit 78", + "}", 'native_home="${home_dir%/}/.codex"', 'client_home="${OCX_CLIENT_CODEX_HOME:-${home_dir%/}/.codex-ocx}"', - 'client_home="${client_home%/}"', + 'case "$client_home" in', + " /*) ;;", + ' *) echo "OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute" >&2; exit 78 ;;', + "esac", + 'client_home="$(printf "%s\\n" "$client_home" | awk -F/ \'{', + " n = 0;", + " for (i = 1; i <= NF; i++) {", + ' if ($i == "" || $i == ".") continue;', + ' if ($i == "..") { if (n > 0) n--; continue; }', + " parts[++n] = $i;", + " }", + ' out = "/";', + ' for (i = 1; i <= n; i++) out = out (i == 1 ? "" : "/") parts[i];', + " print out;", + "}')\"", + 'path_part=""', + 'path_rest="${client_home#/}"', + 'while [ -n "$path_rest" ]; do', + ' path_component="${path_rest%%/*}"', + ' if [ "$path_rest" = "$path_component" ]; then path_rest=""; else path_rest="${path_rest#*/}"; fi', + ' [ -n "$path_component" ] || continue', + ' path_part="${path_part}/${path_component}"', + ' if [ -L "$path_part" ]; then', + ' echo "OCX client-only: refusing symlinked Codex home path $path_part" >&2', + " exit 78", + " fi", + "done", 'if [ "$client_home" = "$native_home" ]; then', ' echo "OCX client-only: refusing native Codex home $native_home" >&2', " exit 78", @@ -72,6 +103,44 @@ export const CODEX_CLIENT_SHIM = [ "", ].join("\n"); +export const CODEX_CLIENT_POWERSHELL_SHIM = [ + "# OpenCodex client-only Codex shim for PowerShell.", + "$ErrorActionPreference = 'Stop'", + "$homeRoot = if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { $env:HOME } elseif (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $env:USERPROFILE } else { Write-Error 'OCX client-only: HOME or USERPROFILE is required'; exit 78 }", + "$homeDir = [System.IO.Path]::GetFullPath($homeRoot)", + "$nativeHome = [System.IO.Path]::GetFullPath((Join-Path $homeDir '.codex'))", + "$clientHomeRaw = if ($env:OCX_CLIENT_CODEX_HOME) { $env:OCX_CLIENT_CODEX_HOME } else { Join-Path $homeDir '.codex-ocx' }", + "if (-not [System.IO.Path]::IsPathRooted($clientHomeRaw)) { Write-Error 'OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute'; exit 78 }", + "$clientHome = [System.IO.Path]::GetFullPath($clientHomeRaw)", + "function Test-ReparsePointPath([string]$Path) {", + " $root = [System.IO.Path]::GetPathRoot($Path)", + " $current = $root", + " $relative = $Path.Substring($root.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)", + " foreach ($part in $relative) {", + " $current = Join-Path $current $part", + " if (Test-Path -LiteralPath $current) {", + " if ((Get-Item -Force -LiteralPath $current).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true }", + " }", + " }", + " return $false", + "}", + "if ([string]::Equals($clientHome, $nativeHome, [System.StringComparison]::OrdinalIgnoreCase) -or (Test-ReparsePointPath $clientHome)) {", + ' Write-Error "OCX client-only: refusing native or symlinked Codex home $clientHome"', + " exit 78", + "}", + "$env:CODEX_HOME = $clientHome", + "$tokenFile = if ($env:OCX_CLIENT_TOKEN_FILE) { $env:OCX_CLIENT_TOKEN_FILE } else { Join-Path $homeDir '.opencodex\\service-api-token' }", + "if (-not $env:OPENCODEX_API_KEY -and (Test-Path -LiteralPath $tokenFile -PathType Leaf)) { $env:OPENCODEX_API_KEY = (Get-Content -Raw -LiteralPath $tokenFile).Trim() }", + "if (-not $env:OPENCODEX_API_AUTH_TOKEN -and $env:OPENCODEX_API_KEY) { $env:OPENCODEX_API_AUTH_TOKEN = $env:OPENCODEX_API_KEY }", + "$ocxBin = if ($env:OCX_CLIENT_OCX_BIN) { $env:OCX_CLIENT_OCX_BIN } else { Join-Path $homeDir '.local\\bin\\ocx.cmd' }", + "$codexBin = if ($env:OCX_CLIENT_CODEX_BIN) { $env:OCX_CLIENT_CODEX_BIN } else { Join-Path $homeDir '.local\\bin\\codex.opencodex-real.cmd' }", + "$skipEnsure = @('agents', 'app-server', 'apply', 'cloud', 'completion', 'doctor', 'exec-server', 'features', 'help', 'login', 'logout', 'mcp-server', 'plugin', 'remote-control', 'update', '--help', '-h', '--version', '-V', 'debug') -contains ($args | Select-Object -First 1)", + "if (-not $skipEnsure) { & $ocxBin ensure *> $null; if ($LASTEXITCODE -ne 0) { Write-Error 'Codex: central OCX proxy unavailable through the governed remote launcher'; exit 69 } }", + "& $codexBin @args", + "exit $LASTEXITCODE", + "", +].join("\r\n"); + export async function buildClientArtifact(destination: string, root = ROOT) { const output = resolve(destination); if (existsSync(output)) @@ -101,6 +170,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { try { const result = await Bun.build({ entrypoints: [join(root, "src/cli/index.ts")], + root, target: "bun", format: "esm", packages: "bundle", @@ -125,6 +195,10 @@ export async function buildClientArtifact(destination: string, root = ROOT) { mode: 0o755, }); chmodSync(join(staging, "bin/codex.ocx-client"), 0o755); + writeFileSync( + join(staging, "bin/codex.ocx-client.ps1"), + CODEX_CLIENT_POWERSHELL_SHIM, + ); // Package metadata is read relative to src/cli/index.js by the CLI. // The upstream model JSON and imported dependencies are bundled by Bun. writeFileSync(join(staging, "package.json"), packageText); @@ -142,6 +216,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { files: { "src/cli/index.js": digest, "bin/codex.ocx-client": sha256(CODEX_CLIENT_SHIM), + "bin/codex.ocx-client.ps1": sha256(CODEX_CLIENT_POWERSHELL_SHIM), "package.json": sha256(packageText), }, activation: "not-activated", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 43a6e88f..19248988 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -100,13 +100,19 @@ describe("remote client artifact", () => { ); expect(readFileSync(entry, "utf8")).toContain("syncExternalOcxCatalog"); for (const command of [ - "start", - "ensure", - "service", - "init", - "__startup-health", + ["start"], + ["ensure"], + ["service"], + ["init"], + ["__startup-health"], + ["sync"], + ["sync", "--restart-codex"], + ["sync-cache", "--restart-codex"], + ["v2", "mode", "v2"], + ["recover-history", "--legacy-openai"], + ["codex-shim", "install"], ]) { - const denied = Bun.spawnSync([process.execPath, entry, command], { + const denied = Bun.spawnSync([process.execPath, entry, ...command], { env, cwd: scratch, }); @@ -137,6 +143,7 @@ describe("remote client artifact", () => { const output = join(scratch, "shim-candidate"); const manifest = await buildClientArtifact(output); const shim = join(output, "bin/codex.ocx-client"); + const powershellShim = join(output, "bin/codex.ocx-client.ps1"); const home = join(scratch, "shim-home"); const nativeHome = join(home, ".codex"); const capture = join(scratch, "captured-home"); @@ -164,6 +171,13 @@ describe("remote client artifact", () => { expect(manifest.files["bin/codex.ocx-client"]).toBe( createHash("sha256").update(readFileSync(shim)).digest("hex"), ); + expect(manifest.files["bin/codex.ocx-client.ps1"]).toBe( + createHash("sha256").update(readFileSync(powershellShim)).digest("hex"), + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "Test-ReparsePointPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain("$env:USERPROFILE"); const defaultRun = Bun.spawnSync([shim, "--version"], { env }); expect(defaultRun.exitCode).toBe(0); expect(readFileSync(capture, "utf8")).toBe(join(home, ".codex-ocx") + "\n"); @@ -171,6 +185,27 @@ describe("remote client artifact", () => { "direct Azure config stays untouched\n", ); + const normalizedNativeRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: join(nativeHome, "..", ".codex") }, + }); + expect(normalizedNativeRun.exitCode).toBe(78); + expect(normalizedNativeRun.stderr.toString()).toContain( + "refusing native Codex home", + ); + + const nativeAlias = join(home, "native-codex-alias"); + symlinkSync(nativeHome, nativeAlias); + const symlinkedNativeRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: nativeAlias }, + }); + expect(symlinkedNativeRun.exitCode).toBe(78); + expect(symlinkedNativeRun.stderr.toString()).toContain( + "refusing symlinked Codex home path", + ); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); + const selected = join(home, ".codex-client-test"); const selectedRun = Bun.spawnSync([shim, "--version"], { env: { ...env, OCX_CLIENT_CODEX_HOME: selected }, From ffcce798e1c17dd0aab4923e48b5cbb101c6212c Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 03:20:58 +0200 Subject: [PATCH 03/17] fix: block client artifact repair paths --- docs/client-artifact.md | 8 ++-- scripts/build-client-artifact.ts | 25 +++++++++-- tests/client-artifact.test.ts | 72 ++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index 5930a779..ef9ab2a1 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -28,7 +28,8 @@ The artifact preserves the remote launcher's contract: `src/cli/index.js`, for an operator-controlled install step. The shims always select `OCX_CLIENT_CODEX_HOME` or its isolated default `~/.codex-ocx`; they ignore an inherited `CODEX_HOME` and explicitly refuse the native -`~/.codex` home, including normalized aliases and symlinked paths. An explicit +`~/.codex` home, including normalized aliases, symlinked paths, and the +physical target of a symlinked native home. An explicit `OCX_CLIENT_CODEX_HOME` must be absolute. `OCX_CLIENT_OCX_BIN` and `OCX_CLIENT_CODEX_BIN` can select the governed remote launcher and real Codex executable during installation. @@ -36,8 +37,9 @@ executable during installation. The CLI and its imported dependencies and upstream model snapshot are bundled; package metadata remains alongside the bundle for version reporting. This is not a GUI, tray, service or storage-worker distribution. Direct bundle use is -limited to read-only help, version, status, and health commands; every other -command fails closed and must go through the governed remote launcher. +limited to help and version commands, which exit before CLI auto-repair hooks; +every other command fails closed and must go through the governed remote +launcher. Continue using the existing governed remote launcher for remote connectivity and command authorization; it prevents indirect lifecycle paths too. Building a candidate does not replace a globally installed shim or modify either the diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 53c20174..c09c8b04 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -27,10 +27,10 @@ function git(root: string, ...args: string[]): string { } // The remote wrapper owns all mutation and lifecycle behavior. Direct bundle use -// is deliberately limited to a small read-only surface. +// stops before the CLI's auto-repair hooks can run. export const CLIENT_GUARD = ` const clientCommand = process.argv[2] || ""; -const clientReadOnlyCommands = new Set(["", "help", "--help", "-h", "version", "--version", "-v", "status", "health"]); +const clientReadOnlyCommands = new Set(["", "help", "--help", "-h", "version", "--version", "-v"]); if (!clientReadOnlyCommands.has(clientCommand)) { console.error("OCX client artifact: local lifecycle commands are disabled; use the remote launcher."); process.exit(64); @@ -46,6 +46,12 @@ export const CODEX_CLIENT_SHIM = [ " exit 78", "}", 'native_home="${home_dir%/}/.codex"', + 'if [ -e "$native_home" ] || [ -L "$native_home" ]; then', + ' native_home="$(cd -P -- "$native_home" && pwd -P)" || {', + ' echo "OCX client-only: native Codex home must resolve to an accessible directory" >&2', + " exit 78", + " }", + "fi", 'client_home="${OCX_CLIENT_CODEX_HOME:-${home_dir%/}/.codex-ocx}"', 'case "$client_home" in', " /*) ;;", @@ -74,6 +80,12 @@ export const CODEX_CLIENT_SHIM = [ " exit 78", " fi", "done", + 'if [ -e "$client_home" ]; then', + ' client_home="$(cd -P -- "$client_home" && pwd -P)" || {', + ' echo "OCX client-only: client Codex home must resolve to an accessible directory" >&2', + " exit 78", + " }", + "fi", 'if [ "$client_home" = "$native_home" ]; then', ' echo "OCX client-only: refusing native Codex home $native_home" >&2', " exit 78", @@ -108,10 +120,15 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ "$ErrorActionPreference = 'Stop'", "$homeRoot = if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { $env:HOME } elseif (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $env:USERPROFILE } else { Write-Error 'OCX client-only: HOME or USERPROFILE is required'; exit 78 }", "$homeDir = [System.IO.Path]::GetFullPath($homeRoot)", - "$nativeHome = [System.IO.Path]::GetFullPath((Join-Path $homeDir '.codex'))", + "function Resolve-CanonicalPath([string]$Path) {", + " $full = [System.IO.Path]::GetFullPath($Path)", + " if (Test-Path -LiteralPath $full) { return (Resolve-Path -LiteralPath $full -ErrorAction Stop).ProviderPath }", + " return $full", + "}", + "$nativeHome = Resolve-CanonicalPath (Join-Path $homeDir '.codex')", "$clientHomeRaw = if ($env:OCX_CLIENT_CODEX_HOME) { $env:OCX_CLIENT_CODEX_HOME } else { Join-Path $homeDir '.codex-ocx' }", "if (-not [System.IO.Path]::IsPathRooted($clientHomeRaw)) { Write-Error 'OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute'; exit 78 }", - "$clientHome = [System.IO.Path]::GetFullPath($clientHomeRaw)", + "$clientHome = Resolve-CanonicalPath $clientHomeRaw", "function Test-ReparsePointPath([string]$Path) {", " $root = [System.IO.Path]::GetPathRoot($Path)", " $current = $root", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 19248988..046be2e1 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -111,6 +111,8 @@ describe("remote client artifact", () => { ["v2", "mode", "v2"], ["recover-history", "--legacy-openai"], ["codex-shim", "install"], + ["status"], + ["health"], ]) { const denied = Bun.spawnSync([process.execPath, entry, ...command], { env, @@ -121,6 +123,38 @@ describe("remote client artifact", () => { "local lifecycle commands are disabled", ); } + const staleShimHome = join(scratch, "stale-shim-home"); + const staleShimBin = join(scratch, "stale-shim-bin"); + const staleWrapper = join(staleShimBin, "codex"); + const staleBackup = join(staleShimBin, "codex.opencodex-real"); + const staleReplacement = + "replacement that direct artifact status must not promote\n"; + mkdirSync(staleShimHome); + mkdirSync(staleShimBin); + writeFileSync(staleWrapper, staleReplacement); + writeFileSync(staleBackup, "known-good prior launcher\n"); + writeFileSync( + join(staleShimHome, "codex-shim.json"), + `${JSON.stringify({ + platform: process.platform, + wrapperPath: staleWrapper, + originalPath: staleWrapper, + backupPath: staleBackup, + })}\n`, + ); + const staleState = readFileSync(join(staleShimHome, "codex-shim.json")); + const staleRun = Bun.spawnSync([process.execPath, entry, "status"], { + env: { ...env, OPENCODEX_HOME: staleShimHome, PATH: staleShimBin }, + cwd: scratch, + }); + expect(staleRun.exitCode).toBe(64); + expect(readFileSync(staleWrapper, "utf8")).toBe(staleReplacement); + expect(readFileSync(staleBackup, "utf8")).toBe( + "known-good prior launcher\n", + ); + expect(readFileSync(join(staleShimHome, "codex-shim.json"))).toEqual( + staleState, + ); expect(existsSync(join(scratch, "ocx-home", "proxy.pid"))).toBe(false); await expect(buildClientArtifact(output)).rejects.toThrow( "Destination already exists", @@ -177,6 +211,15 @@ describe("remote client artifact", () => { expect(readFileSync(powershellShim, "utf8")).toContain( "Test-ReparsePointPath", ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "Resolve-CanonicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$nativeHome = Resolve-CanonicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$clientHome = Resolve-CanonicalPath", + ); expect(readFileSync(powershellShim, "utf8")).toContain("$env:USERPROFILE"); const defaultRun = Bun.spawnSync([shim, "--version"], { env }); expect(defaultRun.exitCode).toBe(0); @@ -224,6 +267,35 @@ describe("remote client artifact", () => { ); }); + test("refuses the physical target of a symlinked native Codex home", async () => { + const output = join(scratch, "symlinked-native-candidate"); + await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client"); + const home = join(scratch, "symlinked-native-home"); + const nativeHome = join(home, ".codex"); + const nativeTarget = join(scratch, "native-codex-target"); + const real = join(scratch, "symlinked-native-real"); + mkdirSync(home); + mkdirSync(nativeTarget); + symlinkSync(nativeTarget, nativeHome, "dir"); + writeFileSync(join(nativeTarget, "config.toml"), "native config\n"); + writeFileSync(real, "#!/usr/bin/env sh\nexit 0\n", { mode: 0o755 }); + const result = Bun.spawnSync([shim, "--version"], { + env: { + ...process.env, + HOME: home, + OCX_CLIENT_CODEX_HOME: nativeTarget, + OCX_CLIENT_CODEX_BIN: real, + OCX_CLIENT_OCX_BIN: "/bin/false", + }, + }); + expect(result.exitCode).toBe(78); + expect(result.stderr.toString()).toContain("refusing native Codex home"); + expect(readFileSync(join(nativeTarget, "config.toml"), "utf8")).toBe( + "native config\n", + ); + }); + test("does not replace an existing destination symlink", async () => { const existing = join(scratch, "existing"); writeFileSync(existing, "preserve"); From df88b66a809cb46eaa8ce495cf08559fd825416f Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 03:27:08 +0200 Subject: [PATCH 04/17] fix: resolve PowerShell client home links --- docs/client-artifact.md | 5 ++++ scripts/build-client-artifact.ts | 51 +++++++++++++++++++++++++------- tests/client-artifact.test.ts | 46 ++++++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index ef9ab2a1..e58616d7 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -34,6 +34,11 @@ physical target of a symlinked native home. An explicit `OCX_CLIENT_CODEX_BIN` can select the governed remote launcher and real Codex executable during installation. +The PowerShell shim resolves every existing reparse-point component to its +physical target before comparing homes, and fails closed if a target cannot be +resolved. It separately rejects a client-home override that traverses a +reparse point, so no link alias can select the native home. + The CLI and its imported dependencies and upstream model snapshot are bundled; package metadata remains alongside the bundle for version reporting. This is not a GUI, tray, service or storage-worker distribution. Direct bundle use is diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index c09c8b04..7e145aae 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -118,31 +118,60 @@ export const CODEX_CLIENT_SHIM = [ export const CODEX_CLIENT_POWERSHELL_SHIM = [ "# OpenCodex client-only Codex shim for PowerShell.", "$ErrorActionPreference = 'Stop'", - "$homeRoot = if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { $env:HOME } elseif (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $env:USERPROFILE } else { Write-Error 'OCX client-only: HOME or USERPROFILE is required'; exit 78 }", + "$homeRoot = if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { $env:HOME } elseif (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { $env:USERPROFILE } else { [Console]::Error.WriteLine('OCX client-only: HOME or USERPROFILE is required'); exit 78 }", "$homeDir = [System.IO.Path]::GetFullPath($homeRoot)", - "function Resolve-CanonicalPath([string]$Path) {", + "function Resolve-PhysicalPath([string]$Path, [int]$Depth = 0) {", + " if ($Depth -gt 40) { throw 'OCX client-only: reparse-point resolution exceeded the safe depth' }", " $full = [System.IO.Path]::GetFullPath($Path)", - " if (Test-Path -LiteralPath $full) { return (Resolve-Path -LiteralPath $full -ErrorAction Stop).ProviderPath }", - " return $full", + " $root = [System.IO.Path]::GetPathRoot($full)", + " $current = $root", + " $relative = $full.Substring($root.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)", + " foreach ($part in $relative) {", + " $candidate = Join-Path $current $part", + " $item = Get-Item -Force -LiteralPath $candidate -ErrorAction SilentlyContinue", + " if ($null -eq $item) { $current = $candidate; continue }", + " if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {", + " $resolver = $item.PSObject.Methods['ResolveLinkTarget']", + " if ($null -ne $resolver) {", + " $target = $item.ResolveLinkTarget($true)", + ' if ($null -eq $target) { throw "OCX client-only: cannot resolve reparse point $candidate" }', + " $targetPath = $target.FullName", + " } else {", + " $targets = @($item.Target)", + ' if ($targets.Count -ne 1 -or [string]::IsNullOrWhiteSpace([string]$targets[0])) { throw "OCX client-only: cannot resolve reparse point $candidate" }', + " $targetPath = [string]$targets[0]", + " if (-not [System.IO.Path]::IsPathRooted($targetPath)) { $targetPath = Join-Path (Split-Path -Parent $candidate) $targetPath }", + " }", + " $current = Resolve-PhysicalPath $targetPath ($Depth + 1)", + " } else { $current = $candidate }", + " }", + " return [System.IO.Path]::GetFullPath($current)", "}", - "$nativeHome = Resolve-CanonicalPath (Join-Path $homeDir '.codex')", "$clientHomeRaw = if ($env:OCX_CLIENT_CODEX_HOME) { $env:OCX_CLIENT_CODEX_HOME } else { Join-Path $homeDir '.codex-ocx' }", - "if (-not [System.IO.Path]::IsPathRooted($clientHomeRaw)) { Write-Error 'OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute'; exit 78 }", - "$clientHome = Resolve-CanonicalPath $clientHomeRaw", + "if (-not [System.IO.Path]::IsPathRooted($clientHomeRaw)) { [Console]::Error.WriteLine('OCX client-only: OCX_CLIENT_CODEX_HOME must be absolute'); exit 78 }", + "$clientHomeCandidate = [System.IO.Path]::GetFullPath($clientHomeRaw)", "function Test-ReparsePointPath([string]$Path) {", " $root = [System.IO.Path]::GetPathRoot($Path)", " $current = $root", " $relative = $Path.Substring($root.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)", " foreach ($part in $relative) {", " $current = Join-Path $current $part", - " if (Test-Path -LiteralPath $current) {", - " if ((Get-Item -Force -LiteralPath $current).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true }", + " $item = Get-Item -Force -LiteralPath $current -ErrorAction SilentlyContinue", + " if ($null -ne $item) {", + " if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true }", " }", " }", " return $false", "}", - "if ([string]::Equals($clientHome, $nativeHome, [System.StringComparison]::OrdinalIgnoreCase) -or (Test-ReparsePointPath $clientHome)) {", - ' Write-Error "OCX client-only: refusing native or symlinked Codex home $clientHome"', + "try {", + " $nativeHome = Resolve-PhysicalPath (Join-Path $homeDir '.codex')", + " $clientHome = Resolve-PhysicalPath $clientHomeCandidate", + "} catch {", + ' [Console]::Error.WriteLine("OCX client-only: cannot resolve Codex home: $($_.Exception.Message)")', + " exit 78", + "}", + "if ([string]::Equals($clientHome, $nativeHome, [System.StringComparison]::OrdinalIgnoreCase) -or (Test-ReparsePointPath $clientHomeCandidate)) {", + ' [Console]::Error.WriteLine("OCX client-only: refusing native or symlinked Codex home $clientHomeCandidate")', " exit 78", "}", "$env:CODEX_HOME = $clientHome", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 046be2e1..d29addc3 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url"; import { buildClientArtifact } from "../scripts/build-client-artifact"; const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); +const powershell = Bun.which("pwsh"); afterAll(() => rmSync(scratch, { recursive: true, force: true })); describe("remote client artifact", () => { @@ -212,13 +213,19 @@ describe("remote client artifact", () => { "Test-ReparsePointPath", ); expect(readFileSync(powershellShim, "utf8")).toContain( - "Resolve-CanonicalPath", + "Resolve-PhysicalPath", ); expect(readFileSync(powershellShim, "utf8")).toContain( - "$nativeHome = Resolve-CanonicalPath", + "$item.ResolveLinkTarget($true)", ); expect(readFileSync(powershellShim, "utf8")).toContain( - "$clientHome = Resolve-CanonicalPath", + "$targets = @($item.Target)", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$nativeHome = Resolve-PhysicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$clientHome = Resolve-PhysicalPath", ); expect(readFileSync(powershellShim, "utf8")).toContain("$env:USERPROFILE"); const defaultRun = Bun.spawnSync([shim, "--version"], { env }); @@ -296,6 +303,39 @@ describe("remote client artifact", () => { ); }); + test.skipIf(!powershell)( + "PowerShell refuses the physical target of a symlinked native Codex home", + async () => { + const output = join(scratch, "powershell-symlinked-native-candidate"); + await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client.ps1"); + const home = join(scratch, "powershell-symlinked-native-home"); + const nativeHome = join(home, ".codex"); + const nativeTarget = join(scratch, "powershell-native-codex-target"); + mkdirSync(home); + mkdirSync(nativeTarget); + symlinkSync(nativeTarget, nativeHome, "dir"); + writeFileSync(join(nativeTarget, "config.toml"), "native config\n"); + const result = Bun.spawnSync( + [powershell!, "-NoProfile", "-File", shim, "--version"], + { + env: { + ...process.env, + HOME: home, + OCX_CLIENT_CODEX_HOME: nativeTarget, + }, + }, + ); + expect(result.exitCode).toBe(78); + expect(result.stderr.toString()).toContain( + "refusing native or symlinked Codex home", + ); + expect(readFileSync(join(nativeTarget, "config.toml"), "utf8")).toBe( + "native config\n", + ); + }, + ); + test("does not replace an existing destination symlink", async () => { const existing = join(scratch, "existing"); writeFileSync(existing, "preserve"); From b032378c039a557d67f2fd165036f657b09af14b Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 6 Sep 2026 06:44:58 +0200 Subject: [PATCH 05/17] fix(client): bind artifacts to committed builder --- docs/client-artifact.md | 2 +- scripts/build-client-artifact.ts | 14 ++++++++++++++ tests/client-artifact.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index e58616d7..ead464d7 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -20,7 +20,7 @@ The destination must not exist. This command never activates a candidate, changes a `current` link, reads home configuration or starts a proxy. Runtime source and lockfile changes must be committed first. Source provenance is the checkout HEAD; package metadata comes from that Git revision, while the -manifest separately records the builder digest and Bun version. +manifest separately records the committed builder source revision, builder digest and Bun version. The artifact preserves the remote launcher's contract: `src/cli/index.js`, `package.json`, `source-sha` and `index.js.sha256`. It also ships the executable diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 7e145aae..d2a9747a 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -188,6 +188,19 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ ].join("\r\n"); export async function buildClientArtifact(destination: string, root = ROOT) { + const builderDirty = git( + ROOT, + "status", + "--porcelain", + "--untracked-files=all", + "--", + "scripts/build-client-artifact.ts", + ); + if (builderDirty) + throw new Error( + "Artifact builder is dirty; commit the reviewed builder before building", + ); + const builderSourceSha = git(ROOT, "rev-parse", "HEAD"); const output = resolve(destination); if (existsSync(output)) throw new Error( @@ -258,6 +271,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { sourceSha, bunVersion: Bun.version, lockSha256: sha256(lock), + builderSourceSha, builderSha256: sha256(readFileSync(fileURLToPath(import.meta.url))), files: { "src/cli/index.js": digest, diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index d29addc3..a7eb8c46 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -37,6 +37,35 @@ describe("remote client artifact", () => { ); }); + test("refuses an uncommitted artifact builder", () => { + const root = join(import.meta.dir, ".."); + const dirtyRoot = join(scratch, "dirty-builder-checkout"); + const clone = Bun.spawnSync(["git", "clone", "--shared", root, dirtyRoot]); + expect(clone.success).toBe(true); + const builder = join(dirtyRoot, "scripts/build-client-artifact.ts"); + writeFileSync( + builder, + readFileSync(builder, "utf8") + "\n// dirty builder probe\n", + ); + const output = join(scratch, "dirty-builder-candidate"); + const result = Bun.spawnSync( + [ + process.execPath, + builder, + "--output", + output, + "--source-root", + dirtyRoot, + ], + { cwd: dirtyRoot }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain( + "Artifact builder is dirty; commit the reviewed builder before building", + ); + expect(existsSync(output)).toBe(false); + }); + test("builds a self-contained, SHA-bound candidate without activation", async () => { const output = join(scratch, "candidate"); const manifest = await buildClientArtifact(output); @@ -73,6 +102,7 @@ describe("remote client artifact", () => { expect(manifest.lockSha256).toBe( createHash("sha256").update(lock).digest("hex"), ); + expect(manifest.builderSourceSha).toBe(sourceSha); expect(manifest.builderSha256).toBe( createHash("sha256").update(builder).digest("hex"), ); From 654f8e2ead6b0a03dfc5574a6d54da4919c146de Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 6 Sep 2026 07:08:44 +0200 Subject: [PATCH 06/17] fix(client): reject symlinked artifact destinations --- scripts/build-client-artifact.ts | 30 ++++++++++++++++++++++++++++++ tests/client-artifact.test.ts | 13 +++++++++++++ 2 files changed, 43 insertions(+) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index d2a9747a..66e6560b 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -16,6 +17,32 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url)); const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex"); +function assertNoSymlinkPathComponents(path: string) { + let current = resolve(path); + while (true) { + try { + if (lstatSync(current).isSymbolicLink()) { + throw new Error( + `Destination path traverses a symlink; refusing publication: ${current}`, + ); + } + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + // Missing components are expected for a fresh candidate path. + } else { + throw error; + } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } +} + function git(root: string, ...args: string[]): string { const result = Bun.spawnSync(["git", ...args], { cwd: root, @@ -206,6 +233,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { throw new Error( "Destination already exists; build a new candidate instead", ); + assertNoSymlinkPathComponents(output); // Build only clean, tracked runtime inputs. Tooling/docs edits do not invalidate // the runtime revision; package metadata is read from the exact Git object. const dirty = git( @@ -286,6 +314,8 @@ export async function buildClientArtifact(destination: string, root = ROOT) { JSON.stringify(manifest, null, 2) + "\n", ); // Never touch `current`; publication creates one new candidate directory. + // Re-check path confinement after the potentially long bundle build. + assertNoSymlinkPathComponents(output); if (existsSync(output)) throw new Error( "Destination appeared during build; refusing replacement", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index a7eb8c46..ef952df0 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -366,6 +366,19 @@ describe("remote client artifact", () => { }, ); + test("refuses publication through a symlinked destination parent", async () => { + const realParent = join(scratch, "real-publication-parent"); + const aliasParent = join(scratch, "aliased-publication-parent"); + mkdirSync(realParent); + symlinkSync(realParent, aliasParent, "dir"); + const destination = join(aliasParent, "candidate"); + + await expect(buildClientArtifact(destination)).rejects.toThrow( + "Destination path traverses a symlink", + ); + expect(existsSync(join(realParent, "candidate"))).toBe(false); + }); + test("does not replace an existing destination symlink", async () => { const existing = join(scratch, "existing"); writeFileSync(existing, "preserve"); From 47166e4b21a6ff46f8282cfb06b0318706f3fc18 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Sun, 6 Sep 2026 08:16:25 +0200 Subject: [PATCH 07/17] fix(client): preserve proxy failure exit code --- scripts/build-client-artifact.ts | 2 +- tests/client-artifact.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 66e6560b..4459cbda 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -208,7 +208,7 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ "$ocxBin = if ($env:OCX_CLIENT_OCX_BIN) { $env:OCX_CLIENT_OCX_BIN } else { Join-Path $homeDir '.local\\bin\\ocx.cmd' }", "$codexBin = if ($env:OCX_CLIENT_CODEX_BIN) { $env:OCX_CLIENT_CODEX_BIN } else { Join-Path $homeDir '.local\\bin\\codex.opencodex-real.cmd' }", "$skipEnsure = @('agents', 'app-server', 'apply', 'cloud', 'completion', 'doctor', 'exec-server', 'features', 'help', 'login', 'logout', 'mcp-server', 'plugin', 'remote-control', 'update', '--help', '-h', '--version', '-V', 'debug') -contains ($args | Select-Object -First 1)", - "if (-not $skipEnsure) { & $ocxBin ensure *> $null; if ($LASTEXITCODE -ne 0) { Write-Error 'Codex: central OCX proxy unavailable through the governed remote launcher'; exit 69 } }", + "if (-not $skipEnsure) { & $ocxBin ensure *> $null; if ($LASTEXITCODE -ne 0) { [Console]::Error.WriteLine('Codex: central OCX proxy unavailable through the governed remote launcher'); exit 69 } }", "& $codexBin @args", "exit $LASTEXITCODE", "", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index ef952df0..748cc5e2 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -333,6 +333,31 @@ describe("remote client artifact", () => { ); }); + test.skipIf(!powershell)( + "PowerShell preserves the governed proxy failure exit code", + async () => { + const output = join(scratch, "powershell-proxy-failure-candidate"); + await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client.ps1"); + const home = join(scratch, "powershell-proxy-failure-home"); + const failingOcx = join(scratch, "failing-ocx.ps1"); + mkdirSync(home); + writeFileSync(failingOcx, "exit 7\n"); + const result = Bun.spawnSync([powershell!, "-NoProfile", "-File", shim], { + env: { + ...process.env, + HOME: home, + OCX_CLIENT_CODEX_HOME: join(home, ".codex-ocx"), + OCX_CLIENT_OCX_BIN: failingOcx, + }, + }); + expect(result.exitCode).toBe(69); + expect(result.stderr.toString()).toContain( + "central OCX proxy unavailable through the governed remote launcher", + ); + }, + ); + test.skipIf(!powershell)( "PowerShell refuses the physical target of a symlinked native Codex home", async () => { From 62ebe248ac2a1b7087a5fa72e7b8dc7dd4f0dd9d Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 07:55:59 +0200 Subject: [PATCH 08/17] fix(client): rebuild frozen deps before artifact bundling --- scripts/build-client-artifact.ts | 19 +++++++++++++++++++ tests/client-artifact.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 4459cbda..ee31a5fd 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -252,6 +252,25 @@ export async function buildClientArtifact(destination: string, root = ROOT) { const sourceSha = git(root, "rev-parse", "HEAD"); const packageText = git(root, "show", `${sourceSha}:package.json`) + "\n"; const lock = readFileSync(join(root, "bun.lock")); + // The bundle must be a function of the reviewed source + frozen lock, not of + // whatever ignored node_modules tree happens to be present on the builder. + // Force a script-free frozen reinstall so tampered/stale installed package + // bytes cannot silently enter an artifact while lockSha256 remains unchanged. + const install = Bun.spawnSync( + [ + process.execPath, + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--force", + ], + { cwd: root, stdout: "pipe", stderr: "pipe" }, + ); + if (!install.success) { + throw new Error( + `Locked dependency refresh failed; refusing artifact build: ${install.stderr.toString().trim()}`, + ); + } mkdirSync(dirname(output), { recursive: true }); const staging = mkdtempSync(join(dirname(output), ".ocx-client-build-")); try { diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 748cc5e2..217f5257 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -66,6 +66,34 @@ describe("remote client artifact", () => { expect(existsSync(output)).toBe(false); }); + test("reinstalls frozen dependencies before bundling", async () => { + const root = join(import.meta.dir, ".."); + const sourceRoot = join(scratch, "dependency-drift-checkout"); + const clone = Bun.spawnSync(["git", "clone", "--shared", root, sourceRoot]); + expect(clone.success).toBe(true); + const install = Bun.spawnSync( + [process.execPath, "install", "--frozen-lockfile", "--ignore-scripts"], + { cwd: sourceRoot }, + ); + expect(install.success).toBe(true); + const zodEntry = join(sourceRoot, "node_modules/zod/v4/index.js"); + writeFileSync( + zodEntry, + readFileSync(zodEntry, "utf8") + + '\nconsole.error("DEPENDENCY_DRIFT_SENTINEL");\n', + ); + + const output = join(scratch, "dependency-drift-candidate"); + const manifest = await buildClientArtifact(output, sourceRoot); + expect( + readFileSync(join(output, "src/cli/index.js"), "utf8"), + ).not.toContain("DEPENDENCY_DRIFT_SENTINEL"); + const locked = readFileSync(join(sourceRoot, "bun.lock")); + expect(manifest.lockSha256).toBe( + createHash("sha256").update(locked).digest("hex"), + ); + }); + test("builds a self-contained, SHA-bound candidate without activation", async () => { const output = join(scratch, "candidate"); const manifest = await buildClientArtifact(output); From d2aa96c03c351491d2cd2de53b9ff90450d7a0b2 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 09:40:11 +0200 Subject: [PATCH 09/17] fix(client): confine artifact publication parent --- docs/client-artifact.md | 7 ++-- scripts/build-client-artifact.ts | 55 ++++++++++++++++++++++++++++++-- tests/client-artifact.test.ts | 16 ++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index ead464d7..1b9e650c 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -16,8 +16,11 @@ bun run build:client --output /path/to/client-artifacts/new-candidate \ --source-root /path/to/clean-pinned-checkout ``` -The destination must not exist. This command never activates a candidate, -changes a `current` link, reads home configuration or starts a proxy. Runtime +The destination must not exist. Its parent must resolve without symlinks; on +POSIX it must be owned by the current user and not be group- or world-writable. +This keeps the final same-directory publication confined to a trusted parent. +This command never activates a candidate, changes a `current` link, reads home +configuration or starts a proxy. Runtime source and lockfile changes must be committed first. Source provenance is the checkout HEAD; package metadata comes from that Git revision, while the manifest separately records the committed builder source revision, builder digest and Bun version. diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index ee31a5fd..b7c4985c 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -43,6 +43,45 @@ function assertNoSymlinkPathComponents(path: string) { } } +type PublicationParentIdentity = { dev: number; ino: number; uid: number }; + +function assertTrustedPublicationParent( + parentPath: string, + expected?: PublicationParentIdentity, +): PublicationParentIdentity { + assertNoSymlinkPathComponents(parentPath); + const stats = lstatSync(parentPath); + if (!stats.isDirectory()) { + throw new Error(`Destination parent is not a directory: ${parentPath}`); + } + if (process.platform !== "win32") { + const currentUid = + typeof process.getuid === "function" ? process.getuid() : stats.uid; + if (stats.uid !== currentUid) { + throw new Error( + `Destination parent must be owned by the current user: ${parentPath}`, + ); + } + if ((stats.mode & 0o022) !== 0) { + throw new Error( + `Destination parent must not be group- or world-writable: ${parentPath}`, + ); + } + } + const identity = { dev: stats.dev, ino: stats.ino, uid: stats.uid }; + if ( + expected && + (identity.dev !== expected.dev || + identity.ino !== expected.ino || + identity.uid !== expected.uid) + ) { + throw new Error( + "Destination parent changed during build; refusing publication", + ); + } + return identity; +} + function git(root: string, ...args: string[]): string { const result = Bun.spawnSync(["git", ...args], { cwd: root, @@ -271,8 +310,11 @@ export async function buildClientArtifact(destination: string, root = ROOT) { `Locked dependency refresh failed; refusing artifact build: ${install.stderr.toString().trim()}`, ); } - mkdirSync(dirname(output), { recursive: true }); - const staging = mkdtempSync(join(dirname(output), ".ocx-client-build-")); + const publicationParent = dirname(output); + mkdirSync(publicationParent, { recursive: true, mode: 0o700 }); + const publicationParentIdentity = + assertTrustedPublicationParent(publicationParent); + const staging = mkdtempSync(join(publicationParent, ".ocx-client-build-")); try { const result = await Bun.build({ entrypoints: [join(root, "src/cli/index.ts")], @@ -333,7 +375,14 @@ export async function buildClientArtifact(destination: string, root = ROOT) { JSON.stringify(manifest, null, 2) + "\n", ); // Never touch `current`; publication creates one new candidate directory. - // Re-check path confinement after the potentially long bundle build. + // The parent is owned by this user and not group/world-writable, so no + // unprivileged peer can swap its entries between this identity check and + // the same-directory rename. Re-check both confinement and inode identity + // after the potentially long bundle build before publishing. + assertTrustedPublicationParent( + publicationParent, + publicationParentIdentity, + ); assertNoSymlinkPathComponents(output); if (existsSync(output)) throw new Error( diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 217f5257..6aa44aa1 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -432,6 +433,21 @@ describe("remote client artifact", () => { expect(existsSync(join(realParent, "candidate"))).toBe(false); }); + test.skipIf(process.platform === "win32")( + "refuses publication into a group- or world-writable parent", + async () => { + const unsafeParent = join(scratch, "unsafe-publication-parent"); + mkdirSync(unsafeParent); + chmodSync(unsafeParent, 0o777); + const destination = join(unsafeParent, "candidate"); + + await expect(buildClientArtifact(destination)).rejects.toThrow( + "Destination parent must not be group- or world-writable", + ); + expect(existsSync(destination)).toBe(false); + }, + ); + test("does not replace an existing destination symlink", async () => { const existing = join(scratch, "existing"); writeFileSync(existing, "preserve"); From bafae01e3e2e5e621ada30322342baab859a2235 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 09:49:26 +0200 Subject: [PATCH 10/17] fix(client): isolate frozen dependency refresh --- scripts/build-client-artifact.ts | 75 ++++++++++++++++++++++---------- tests/client-artifact.test.ts | 3 ++ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index b7c4985c..30731ced 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -10,6 +10,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -92,6 +93,44 @@ function git(root: string, ...args: string[]): string { return result.stdout.toString().trim(); } +function prepareIsolatedBuildRoot( + sourceRoot: string, + sourceSha: string, +): string { + const buildRoot = mkdtempSync(join(tmpdir(), "ocx-client-source-")); + try { + git( + sourceRoot, + "clone", + "--shared", + "--no-checkout", + "--quiet", + sourceRoot, + buildRoot, + ); + git(buildRoot, "checkout", "--detach", "--quiet", sourceSha); + const install = Bun.spawnSync( + [ + process.execPath, + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--force", + ], + { cwd: buildRoot, stdout: "pipe", stderr: "pipe" }, + ); + if (!install.success) { + throw new Error( + `Locked dependency refresh failed; refusing artifact build: ${install.stderr.toString().trim()}`, + ); + } + return buildRoot; + } catch (error) { + rmSync(buildRoot, { recursive: true, force: true }); + throw error; + } +} + // The remote wrapper owns all mutation and lifecycle behavior. Direct bundle use // stops before the CLI's auto-repair hooks can run. export const CLIENT_GUARD = ` @@ -291,34 +330,25 @@ export async function buildClientArtifact(destination: string, root = ROOT) { const sourceSha = git(root, "rev-parse", "HEAD"); const packageText = git(root, "show", `${sourceSha}:package.json`) + "\n"; const lock = readFileSync(join(root, "bun.lock")); - // The bundle must be a function of the reviewed source + frozen lock, not of - // whatever ignored node_modules tree happens to be present on the builder. - // Force a script-free frozen reinstall so tampered/stale installed package - // bytes cannot silently enter an artifact while lockSha256 remains unchanged. - const install = Bun.spawnSync( - [ - process.execPath, - "install", - "--frozen-lockfile", - "--ignore-scripts", - "--force", - ], - { cwd: root, stdout: "pipe", stderr: "pipe" }, - ); - if (!install.success) { - throw new Error( - `Locked dependency refresh failed; refusing artifact build: ${install.stderr.toString().trim()}`, - ); - } + // Bundle from an isolated clean clone so the artifact is a function of the + // reviewed source + frozen lock, never of ignored/tampered node_modules in + // the caller checkout. The source checkout remains read-only. + const buildRoot = prepareIsolatedBuildRoot(root, sourceSha); const publicationParent = dirname(output); mkdirSync(publicationParent, { recursive: true, mode: 0o700 }); const publicationParentIdentity = assertTrustedPublicationParent(publicationParent); - const staging = mkdtempSync(join(publicationParent, ".ocx-client-build-")); + let staging: string; + try { + staging = mkdtempSync(join(publicationParent, ".ocx-client-build-")); + } catch (error) { + rmSync(buildRoot, { recursive: true, force: true }); + throw error; + } try { const result = await Bun.build({ - entrypoints: [join(root, "src/cli/index.ts")], - root, + entrypoints: [join(buildRoot, "src/cli/index.ts")], + root: buildRoot, target: "bun", format: "esm", packages: "bundle", @@ -392,6 +422,7 @@ export async function buildClientArtifact(destination: string, root = ROOT) { return manifest; } finally { rmSync(staging, { recursive: true, force: true }); + rmSync(buildRoot, { recursive: true, force: true }); } } diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 6aa44aa1..281e7a46 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -89,6 +89,9 @@ describe("remote client artifact", () => { expect( readFileSync(join(output, "src/cli/index.js"), "utf8"), ).not.toContain("DEPENDENCY_DRIFT_SENTINEL"); + expect(readFileSync(zodEntry, "utf8")).toContain( + "DEPENDENCY_DRIFT_SENTINEL", + ); const locked = readFileSync(join(sourceRoot, "bun.lock")); expect(manifest.lockSha256).toBe( createHash("sha256").update(locked).digest("hex"), From e0dae7adf6b081d08d0f72567fe37b6789266a5a Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 13:19:18 +0200 Subject: [PATCH 11/17] fix(client): canonicalize isolated bundle source paths --- docs/client-artifact.md | 7 +++++-- scripts/build-client-artifact.ts | 31 +++++++++++++++++++++++++++++-- tests/client-artifact.test.ts | 3 +++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index 1b9e650c..c67f59f6 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -42,8 +42,11 @@ physical target before comparing homes, and fails closed if a target cannot be resolved. It separately rejects a client-home override that traverses a reparse point, so no link alias can select the native home. -The CLI and its imported dependencies and upstream model snapshot are bundled; -package metadata remains alongside the bundle for version reporting. This is +The CLI and its imported dependencies and upstream model snapshot are bundled. +Generated Bun source-path comments are canonicalized so the random isolated +build-directory name cannot change artifact bytes between identical builds; a +temporary build path outside generated comments fails the build closed. Package +metadata remains alongside the bundle for version reporting. This is not a GUI, tray, service or storage-worker distribution. Direct bundle use is limited to help and version commands, which exit before CLI auto-repair hooks; every other command fails closed and must go through the governed remote diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 30731ced..d4384406 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); @@ -93,6 +93,32 @@ function git(root: string, ...args: string[]): string { return result.stdout.toString().trim(); } +function normalizeGeneratedBundleSourceComments( + bundle: Uint8Array, + buildRoot: string, +): Uint8Array { + const marker = basename(buildRoot); + const text = new TextDecoder().decode(bundle); + const normalized = text + .split("\n") + .map((line) => { + if (!line.includes(marker)) return line; + if (!line.trimStart().startsWith("// ")) { + throw new Error( + "Temporary build path escaped generated source comments; refusing nondeterministic artifact", + ); + } + return line.replaceAll(marker, "ocx-client-source"); + }) + .join("\n"); + if (normalized.includes(marker)) { + throw new Error( + "Temporary build path remained in bundle after normalization; refusing nondeterministic artifact", + ); + } + return new TextEncoder().encode(normalized); +} + function prepareIsolatedBuildRoot( sourceRoot: string, sourceSha: string, @@ -364,7 +390,8 @@ export async function buildClientArtifact(destination: string, root = ROOT) { throw new Error( "Unexpected bundle assets; extend the artifact manifest before shipping", ); - const bundle = new Uint8Array(await result.outputs[0]!.arrayBuffer()); + const rawBundle = new Uint8Array(await result.outputs[0]!.arrayBuffer()); + const bundle = normalizeGeneratedBundleSourceComments(rawBundle, buildRoot); const digest = sha256(bundle); mkdirSync(join(staging, "src/cli"), { recursive: true }); writeFileSync(join(staging, "src/cli/index.js"), bundle); diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 281e7a46..a349a4f8 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -128,6 +128,9 @@ describe("remote client artifact", () => { `${digest} src/cli/index.js\n`, ); expect(manifest.files["src/cli/index.js"]).toBe(digest); + expect(readFileSync(entry, "utf8")).not.toMatch( + /ocx-client-source-[A-Za-z0-9_-]+/, + ); expect(manifest.files["package.json"]).toBe( createHash("sha256").update(packageText).digest("hex"), ); From 053abb9908b29b6e3cd2a6ba46fda4d63de2269b Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 13:22:07 +0200 Subject: [PATCH 12/17] test(client): budget isolated artifact builds --- scripts/build-client-artifact.ts | 9 +++++---- tests/client-artifact.test.ts | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index d4384406..517df881 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -356,14 +356,15 @@ export async function buildClientArtifact(destination: string, root = ROOT) { const sourceSha = git(root, "rev-parse", "HEAD"); const packageText = git(root, "show", `${sourceSha}:package.json`) + "\n"; const lock = readFileSync(join(root, "bun.lock")); - // Bundle from an isolated clean clone so the artifact is a function of the - // reviewed source + frozen lock, never of ignored/tampered node_modules in - // the caller checkout. The source checkout remains read-only. - const buildRoot = prepareIsolatedBuildRoot(root, sourceSha); const publicationParent = dirname(output); mkdirSync(publicationParent, { recursive: true, mode: 0o700 }); const publicationParentIdentity = assertTrustedPublicationParent(publicationParent); + // Bundle from an isolated clean clone so the artifact is a function of the + // reviewed source + frozen lock, never of ignored/tampered node_modules in + // the caller checkout. The source checkout remains read-only. Validate the + // publication parent first so unsafe destinations fail before dependency I/O. + const buildRoot = prepareIsolatedBuildRoot(root, sourceSha); let staging: string; try { staging = mkdtempSync(join(publicationParent, ".ocx-client-build-")); diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index a349a4f8..2675990d 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -36,7 +36,7 @@ describe("remote client artifact", () => { expect(readFileSync(join(output, "source-sha"), "utf8")).toBe( sourceSha + "\n", ); - }); + }, 15_000); test("refuses an uncommitted artifact builder", () => { const root = join(import.meta.dir, ".."); @@ -96,7 +96,7 @@ describe("remote client artifact", () => { expect(manifest.lockSha256).toBe( createHash("sha256").update(locked).digest("hex"), ); - }); + }, 15_000); test("builds a self-contained, SHA-bound candidate without activation", async () => { const output = join(scratch, "candidate"); @@ -237,7 +237,7 @@ describe("remote client artifact", () => { expect(readFileSync(join(duplicate, "artifact-manifest.json"))).toEqual( readFileSync(join(output, "artifact-manifest.json")), ); - }); + }, 30_000); test("ships a client shim that cannot select the native Codex home", async () => { const output = join(scratch, "shim-candidate"); @@ -337,7 +337,7 @@ describe("remote client artifact", () => { expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( "direct Azure config stays untouched\n", ); - }); + }, 15_000); test("refuses the physical target of a symlinked native Codex home", async () => { const output = join(scratch, "symlinked-native-candidate"); @@ -366,7 +366,7 @@ describe("remote client artifact", () => { expect(readFileSync(join(nativeTarget, "config.toml"), "utf8")).toBe( "native config\n", ); - }); + }, 15_000); test.skipIf(!powershell)( "PowerShell preserves the governed proxy failure exit code", From faa33aa64e0509a2a81ffe10b560c57534568c75 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 13:37:32 +0200 Subject: [PATCH 13/17] test(client): isolate dependency tamper fixture --- tests/client-artifact.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 2675990d..ef325bde 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -78,10 +78,13 @@ describe("remote client artifact", () => { ); expect(install.success).toBe(true); const zodEntry = join(sourceRoot, "node_modules/zod/v4/index.js"); + const originalDependency = readFileSync(zodEntry, "utf8"); + // Bun may hardlink installed package bytes into its shared cache. Unlink the + // fixture entry before tampering so this test cannot poison later installs. + rmSync(zodEntry); writeFileSync( zodEntry, - readFileSync(zodEntry, "utf8") + - '\nconsole.error("DEPENDENCY_DRIFT_SENTINEL");\n', + originalDependency + '\nconsole.error("DEPENDENCY_DRIFT_SENTINEL");\n', ); const output = join(scratch, "dependency-drift-candidate"); From 1361a6120b8bb91fc74984516932e78857606ba6 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 13:50:47 +0200 Subject: [PATCH 14/17] fix(client): close cross-platform artifact review gaps --- scripts/build-client-artifact.ts | 4 +- tests/client-artifact.test.ts | 262 +++++++++++++++++-------------- 2 files changed, 142 insertions(+), 124 deletions(-) diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 517df881..82e2a34a 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -256,7 +256,7 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ " $full = [System.IO.Path]::GetFullPath($Path)", " $root = [System.IO.Path]::GetPathRoot($full)", " $current = $root", - " $relative = $full.Substring($root.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)", + " $relative = $full.Substring($root.Length).Split([char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar), [System.StringSplitOptions]::RemoveEmptyEntries)", " foreach ($part in $relative) {", " $candidate = Join-Path $current $part", " $item = Get-Item -Force -LiteralPath $candidate -ErrorAction SilentlyContinue", @@ -284,7 +284,7 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ "function Test-ReparsePointPath([string]$Path) {", " $root = [System.IO.Path]::GetPathRoot($Path)", " $current = $root", - " $relative = $Path.Substring($root.Length).Split([System.IO.Path]::DirectorySeparatorChar, [System.StringSplitOptions]::RemoveEmptyEntries)", + " $relative = $Path.Substring($root.Length).Split([char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar), [System.StringSplitOptions]::RemoveEmptyEntries)", " foreach ($part in $relative) {", " $current = Join-Path $current $part", " $item = Get-Item -Force -LiteralPath $current -ErrorAction SilentlyContinue", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index ef325bde..28ca6c61 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -18,6 +18,7 @@ import { buildClientArtifact } from "../scripts/build-client-artifact"; const scratch = mkdtempSync(join(tmpdir(), "ocx-client-artifact-test-")); const powershell = Bun.which("pwsh"); +const posixShell = process.platform !== "win32"; afterAll(() => rmSync(scratch, { recursive: true, force: true })); describe("remote client artifact", () => { @@ -242,134 +243,151 @@ describe("remote client artifact", () => { ); }, 30_000); - test("ships a client shim that cannot select the native Codex home", async () => { - const output = join(scratch, "shim-candidate"); - const manifest = await buildClientArtifact(output); - const shim = join(output, "bin/codex.ocx-client"); - const powershellShim = join(output, "bin/codex.ocx-client.ps1"); - const home = join(scratch, "shim-home"); - const nativeHome = join(home, ".codex"); - const capture = join(scratch, "captured-home"); - const real = join(scratch, "codex-real"); - mkdirSync(nativeHome, { recursive: true }); - writeFileSync( - join(nativeHome, "config.toml"), - "direct Azure config stays untouched\n", - ); - writeFileSync( - real, - '#!/usr/bin/env sh\nprintf \'%s\\n\' "$CODEX_HOME" > "$OCX_CAPTURE"\n', - { mode: 0o755 }, - ); - const env = { - ...process.env, - HOME: home, - CODEX_HOME: nativeHome, - OCX_CAPTURE: capture, - OCX_CLIENT_CODEX_BIN: real, - OCX_CLIENT_OCX_BIN: "/bin/false", - }; + test.skipIf(!posixShell)( + "ships a client shim that cannot select the native Codex home", + async () => { + const output = join(scratch, "shim-candidate"); + const manifest = await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client"); + const powershellShim = join(output, "bin/codex.ocx-client.ps1"); + const home = join(scratch, "shim-home"); + const nativeHome = join(home, ".codex"); + const capture = join(scratch, "captured-home"); + const real = join(scratch, "codex-real"); + mkdirSync(nativeHome, { recursive: true }); + writeFileSync( + join(nativeHome, "config.toml"), + "direct Azure config stays untouched\n", + ); + writeFileSync( + real, + '#!/usr/bin/env sh\nprintf \'%s\\n\' "$CODEX_HOME" > "$OCX_CAPTURE"\n', + { mode: 0o755 }, + ); + const env = { + ...process.env, + HOME: home, + CODEX_HOME: nativeHome, + OCX_CAPTURE: capture, + OCX_CLIENT_CODEX_BIN: real, + OCX_CLIENT_OCX_BIN: "/bin/false", + }; - expect(statSync(shim).mode & 0o777).toBe(0o755); - expect(manifest.files["bin/codex.ocx-client"]).toBe( - createHash("sha256").update(readFileSync(shim)).digest("hex"), - ); - expect(manifest.files["bin/codex.ocx-client.ps1"]).toBe( - createHash("sha256").update(readFileSync(powershellShim)).digest("hex"), - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "Test-ReparsePointPath", - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "Resolve-PhysicalPath", - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "$item.ResolveLinkTarget($true)", - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "$targets = @($item.Target)", - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "$nativeHome = Resolve-PhysicalPath", - ); - expect(readFileSync(powershellShim, "utf8")).toContain( - "$clientHome = Resolve-PhysicalPath", - ); - expect(readFileSync(powershellShim, "utf8")).toContain("$env:USERPROFILE"); - const defaultRun = Bun.spawnSync([shim, "--version"], { env }); - expect(defaultRun.exitCode).toBe(0); - expect(readFileSync(capture, "utf8")).toBe(join(home, ".codex-ocx") + "\n"); - expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( - "direct Azure config stays untouched\n", - ); + expect(statSync(shim).mode & 0o777).toBe(0o755); + expect(manifest.files["bin/codex.ocx-client"]).toBe( + createHash("sha256").update(readFileSync(shim)).digest("hex"), + ); + expect(manifest.files["bin/codex.ocx-client.ps1"]).toBe( + createHash("sha256").update(readFileSync(powershellShim)).digest("hex"), + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "Test-ReparsePointPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "Resolve-PhysicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$item.ResolveLinkTarget($true)", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$targets = @($item.Target)", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$nativeHome = Resolve-PhysicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$clientHome = Resolve-PhysicalPath", + ); + expect(readFileSync(powershellShim, "utf8")).toContain( + "$env:USERPROFILE", + ); + const defaultRun = Bun.spawnSync([shim, "--version"], { env }); + expect(defaultRun.exitCode).toBe(0); + expect(readFileSync(capture, "utf8")).toBe( + join(home, ".codex-ocx") + "\n", + ); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); - const normalizedNativeRun = Bun.spawnSync([shim, "--version"], { - env: { ...env, OCX_CLIENT_CODEX_HOME: join(nativeHome, "..", ".codex") }, - }); - expect(normalizedNativeRun.exitCode).toBe(78); - expect(normalizedNativeRun.stderr.toString()).toContain( - "refusing native Codex home", - ); + const normalizedNativeRun = Bun.spawnSync([shim, "--version"], { + env: { + ...env, + OCX_CLIENT_CODEX_HOME: join(nativeHome, "..", ".codex"), + }, + }); + expect(normalizedNativeRun.exitCode).toBe(78); + expect(normalizedNativeRun.stderr.toString()).toContain( + "refusing native Codex home", + ); - const nativeAlias = join(home, "native-codex-alias"); - symlinkSync(nativeHome, nativeAlias); - const symlinkedNativeRun = Bun.spawnSync([shim, "--version"], { - env: { ...env, OCX_CLIENT_CODEX_HOME: nativeAlias }, - }); - expect(symlinkedNativeRun.exitCode).toBe(78); - expect(symlinkedNativeRun.stderr.toString()).toContain( - "refusing symlinked Codex home path", - ); - expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( - "direct Azure config stays untouched\n", - ); + const nativeAlias = join(home, "native-codex-alias"); + symlinkSync(nativeHome, nativeAlias); + const symlinkedNativeRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: nativeAlias }, + }); + expect(symlinkedNativeRun.exitCode).toBe(78); + expect(symlinkedNativeRun.stderr.toString()).toContain( + "refusing symlinked Codex home path", + ); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); - const selected = join(home, ".codex-client-test"); - const selectedRun = Bun.spawnSync([shim, "--version"], { - env: { ...env, OCX_CLIENT_CODEX_HOME: selected }, - }); - expect(selectedRun.exitCode).toBe(0); - expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + const selected = join(home, ".codex-client-test"); + const selectedRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: selected }, + }); + expect(selectedRun.exitCode).toBe(0); + expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); - const nativeRun = Bun.spawnSync([shim, "--version"], { - env: { ...env, OCX_CLIENT_CODEX_HOME: nativeHome }, - }); - expect(nativeRun.exitCode).toBe(78); - expect(nativeRun.stderr.toString()).toContain("refusing native Codex home"); - expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); - expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( - "direct Azure config stays untouched\n", - ); - }, 15_000); + const nativeRun = Bun.spawnSync([shim, "--version"], { + env: { ...env, OCX_CLIENT_CODEX_HOME: nativeHome }, + }); + expect(nativeRun.exitCode).toBe(78); + expect(nativeRun.stderr.toString()).toContain( + "refusing native Codex home", + ); + expect(readFileSync(capture, "utf8")).toBe(selected + "\n"); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); + }, + 15_000, + ); - test("refuses the physical target of a symlinked native Codex home", async () => { - const output = join(scratch, "symlinked-native-candidate"); - await buildClientArtifact(output); - const shim = join(output, "bin/codex.ocx-client"); - const home = join(scratch, "symlinked-native-home"); - const nativeHome = join(home, ".codex"); - const nativeTarget = join(scratch, "native-codex-target"); - const real = join(scratch, "symlinked-native-real"); - mkdirSync(home); - mkdirSync(nativeTarget); - symlinkSync(nativeTarget, nativeHome, "dir"); - writeFileSync(join(nativeTarget, "config.toml"), "native config\n"); - writeFileSync(real, "#!/usr/bin/env sh\nexit 0\n", { mode: 0o755 }); - const result = Bun.spawnSync([shim, "--version"], { - env: { - ...process.env, - HOME: home, - OCX_CLIENT_CODEX_HOME: nativeTarget, - OCX_CLIENT_CODEX_BIN: real, - OCX_CLIENT_OCX_BIN: "/bin/false", - }, - }); - expect(result.exitCode).toBe(78); - expect(result.stderr.toString()).toContain("refusing native Codex home"); - expect(readFileSync(join(nativeTarget, "config.toml"), "utf8")).toBe( - "native config\n", - ); - }, 15_000); + test.skipIf(!posixShell)( + "refuses the physical target of a symlinked native Codex home", + async () => { + const output = join(scratch, "symlinked-native-candidate"); + await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client"); + const home = join(scratch, "symlinked-native-home"); + const nativeHome = join(home, ".codex"); + const nativeTarget = join(scratch, "native-codex-target"); + const real = join(scratch, "symlinked-native-real"); + mkdirSync(home); + mkdirSync(nativeTarget); + symlinkSync(nativeTarget, nativeHome, "dir"); + writeFileSync(join(nativeTarget, "config.toml"), "native config\n"); + writeFileSync(real, "#!/usr/bin/env sh\nexit 0\n", { mode: 0o755 }); + const result = Bun.spawnSync([shim, "--version"], { + env: { + ...process.env, + HOME: home, + OCX_CLIENT_CODEX_HOME: nativeTarget, + OCX_CLIENT_CODEX_BIN: real, + OCX_CLIENT_OCX_BIN: "/bin/false", + }, + }); + expect(result.exitCode).toBe(78); + expect(result.stderr.toString()).toContain("refusing native Codex home"); + expect(readFileSync(join(nativeTarget, "config.toml"), "utf8")).toBe( + "native config\n", + ); + }, + 15_000, + ); test.skipIf(!powershell)( "PowerShell preserves the governed proxy failure exit code", From 27455a60a9a9bbe24561069ec314e972b8882f91 Mon Sep 17 00:00:00 2001 From: ocx-test Date: Sun, 6 Sep 2026 14:59:55 +0200 Subject: [PATCH 15/17] test(client): budget PowerShell artifact build --- tests/client-artifact.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index 28ca6c61..a6e8a203 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -412,6 +412,7 @@ describe("remote client artifact", () => { "central OCX proxy unavailable through the governed remote launcher", ); }, + 15_000, ); test.skipIf(!powershell)( From 9e40bec963cbe92304f205c591b80b5a4093f16a Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 02:02:34 +0200 Subject: [PATCH 16/17] fix(client): confine client homes outside native Codex home --- docs/client-artifact.md | 3 ++- scripts/build-client-artifact.ts | 12 +++++---- tests/client-artifact.test.ts | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index c67f59f6..237a505c 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -32,7 +32,8 @@ for an operator-controlled install step. The shims always select `OCX_CLIENT_CODEX_HOME` or its isolated default `~/.codex-ocx`; they ignore an inherited `CODEX_HOME` and explicitly refuse the native `~/.codex` home, including normalized aliases, symlinked paths, and the -physical target of a symlinked native home. An explicit +physical target of a symlinked native home. A client home inside the native +home is rejected as well. An explicit `OCX_CLIENT_CODEX_HOME` must be absolute. `OCX_CLIENT_OCX_BIN` and `OCX_CLIENT_CODEX_BIN` can select the governed remote launcher and real Codex executable during installation. diff --git a/scripts/build-client-artifact.ts b/scripts/build-client-artifact.ts index 82e2a34a..3184b3fb 100644 --- a/scripts/build-client-artifact.ts +++ b/scripts/build-client-artifact.ts @@ -217,10 +217,12 @@ export const CODEX_CLIENT_SHIM = [ " exit 78", " }", "fi", - 'if [ "$client_home" = "$native_home" ]; then', - ' echo "OCX client-only: refusing native Codex home $native_home" >&2', - " exit 78", - "fi", + 'case "$client_home" in', + ' "$native_home"|"$native_home"/*)', + ' echo "OCX client-only: refusing native Codex home $native_home" >&2', + " exit 78", + " ;;", + "esac", 'export CODEX_HOME="$client_home"', 'token_file="${OCX_CLIENT_TOKEN_FILE:-${home_dir%/}/.opencodex/service-api-token}"', 'if [ -z "${OPENCODEX_API_KEY:-}" ] && [ -f "$token_file" ]; then', @@ -301,7 +303,7 @@ export const CODEX_CLIENT_POWERSHELL_SHIM = [ ' [Console]::Error.WriteLine("OCX client-only: cannot resolve Codex home: $($_.Exception.Message)")', " exit 78", "}", - "if ([string]::Equals($clientHome, $nativeHome, [System.StringComparison]::OrdinalIgnoreCase) -or (Test-ReparsePointPath $clientHomeCandidate)) {", + "if ([string]::Equals($clientHome, $nativeHome, [System.StringComparison]::OrdinalIgnoreCase) -or $clientHome.StartsWith($nativeHome + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -or (Test-ReparsePointPath $clientHomeCandidate)) {", ' [Console]::Error.WriteLine("OCX client-only: refusing native or symlinked Codex home $clientHomeCandidate")', " exit 78", "}", diff --git a/tests/client-artifact.test.ts b/tests/client-artifact.test.ts index a6e8a203..b4f9d112 100644 --- a/tests/client-artifact.test.ts +++ b/tests/client-artifact.test.ts @@ -352,6 +352,20 @@ describe("remote client artifact", () => { expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( "direct Azure config stays untouched\n", ); + + const nestedNativeRun = Bun.spawnSync([shim, "--version"], { + env: { + ...env, + OCX_CLIENT_CODEX_HOME: join(nativeHome, "ocx-client"), + }, + }); + expect(nestedNativeRun.exitCode).toBe(78); + expect(nestedNativeRun.stderr.toString()).toContain( + "refusing native Codex home", + ); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "direct Azure config stays untouched\n", + ); }, 15_000, ); @@ -448,6 +462,37 @@ describe("remote client artifact", () => { }, ); + test.skipIf(!powershell)( + "PowerShell refuses a client home nested under the native Codex home", + async () => { + const output = join(scratch, "powershell-nested-native-candidate"); + await buildClientArtifact(output); + const shim = join(output, "bin/codex.ocx-client.ps1"); + const home = join(scratch, "powershell-nested-native-home"); + const nativeHome = join(home, ".codex"); + mkdirSync(nativeHome, { recursive: true }); + writeFileSync(join(nativeHome, "config.toml"), "native config\n"); + const result = Bun.spawnSync( + [powershell!, "-NoProfile", "-File", shim, "--version"], + { + env: { + ...process.env, + HOME: home, + OCX_CLIENT_CODEX_HOME: join(nativeHome, "ocx-client"), + }, + }, + ); + expect(result.exitCode).toBe(78); + expect(result.stderr.toString()).toContain( + "refusing native or symlinked Codex home", + ); + expect(readFileSync(join(nativeHome, "config.toml"), "utf8")).toBe( + "native config\n", + ); + }, + 15_000, + ); + test("refuses publication through a symlinked destination parent", async () => { const realParent = join(scratch, "real-publication-parent"); const aliasParent = join(scratch, "aliased-publication-parent"); From c0800378b517c4710b7c119cd3ea9cf97f5da119 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 02:15:19 +0200 Subject: [PATCH 17/17] docs(client): document token-file override and precedence --- docs/client-artifact.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/client-artifact.md b/docs/client-artifact.md index 237a505c..54805970 100644 --- a/docs/client-artifact.md +++ b/docs/client-artifact.md @@ -38,6 +38,12 @@ home is rejected as well. An explicit `OCX_CLIENT_CODEX_BIN` can select the governed remote launcher and real Codex executable during installation. +`OCX_CLIENT_TOKEN_FILE` selects the API token file (default +`~/.opencodex/service-api-token`). Both shims read it only when +`OPENCODEX_API_KEY` is unset. They also seed `OPENCODEX_API_AUTH_TOKEN` from +that key when the auth-token variable is unset; explicit environment values +are preserved. + The PowerShell shim resolves every existing reparse-point component to its physical target before comparing homes, and fails closed if a target cannot be resolved. It separately rejects a client-home override that traverses a