diff --git a/docs/client-artifact.md b/docs/client-artifact.md new file mode 100644 index 00000000..54805970 --- /dev/null +++ b/docs/client-artifact.md @@ -0,0 +1,71 @@ +# 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. 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. + +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` 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, symlinked paths, and the +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. + +`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 +reparse point, so no link alias can select the native home. + +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 +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 +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..3184b3fb --- /dev/null +++ b/scripts/build-client-artifact.ts @@ -0,0 +1,494 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, 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 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; + } +} + +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, + stdout: "pipe", + stderr: "pipe", + }); + if (!result.success) throw new Error(`git ${args[0]} failed`); + 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, +): 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 = ` +const clientCommand = process.argv[2] || ""; +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); +} +`; + +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="$(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"', + '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', + " /*) ;;", + ' *) 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 [ -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", + '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', + ' 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 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 { [Console]::Error.WriteLine('OCX client-only: HOME or USERPROFILE is required'); exit 78 }", + "$homeDir = [System.IO.Path]::GetFullPath($homeRoot)", + "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)", + " $root = [System.IO.Path]::GetPathRoot($full)", + " $current = $root", + " $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", + " 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)", + "}", + "$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)) { [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([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", + " if ($null -ne $item) {", + " if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { return $true }", + " }", + " }", + " return $false", + "}", + "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 $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", + "}", + "$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) { [Console]::Error.WriteLine('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 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( + "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( + 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")); + 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-")); + } catch (error) { + rmSync(buildRoot, { recursive: true, force: true }); + throw error; + } + try { + const result = await Bun.build({ + entrypoints: [join(buildRoot, "src/cli/index.ts")], + root: buildRoot, + 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 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); + mkdirSync(join(staging, "bin")); + writeFileSync(join(staging, "bin/codex.ocx-client"), CODEX_CLIENT_SHIM, { + 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); + 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), + builderSourceSha, + builderSha256: sha256(readFileSync(fileURLToPath(import.meta.url))), + 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", + }; + writeFileSync( + join(staging, "artifact-manifest.json"), + JSON.stringify(manifest, null, 2) + "\n", + ); + // Never touch `current`; publication creates one new candidate directory. + // 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( + "Destination appeared during build; refusing replacement", + ); + renameSync(staging, output); + return manifest; + } finally { + rmSync(staging, { recursive: true, force: true }); + rmSync(buildRoot, { 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..b4f9d112 --- /dev/null +++ b/tests/client-artifact.test.ts @@ -0,0 +1,545 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmodSync, + 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-")); +const powershell = Bun.which("pwsh"); +const posixShell = process.platform !== "win32"; +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", + ); + }, 15_000); + + 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("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"); + 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, + originalDependency + '\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"); + 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"), + ); + }, 15_000); + + 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(readFileSync(entry, "utf8")).not.toMatch( + /ocx-client-source-[A-Za-z0-9_-]+/, + ); + expect(manifest.files["package.json"]).toBe( + createHash("sha256").update(packageText).digest("hex"), + ); + 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"), + ); + 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"], + ["sync"], + ["sync", "--restart-codex"], + ["sync-cache", "--restart-codex"], + ["v2", "mode", "v2"], + ["recover-history", "--legacy-openai"], + ["codex-shim", "install"], + ["status"], + ["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", + ); + } + 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", + ); + 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")), + ); + }, 30_000); + + 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", + ); + + 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 }, + }); + 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", + ); + + 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, + ); + + 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", + 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", + ); + }, + 15_000, + ); + + 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.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"); + 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.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"); + 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); + }); +});