diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index bda01e00cb..2da4e5c55d 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -10,7 +10,8 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "../config"; +import { getConfigDir, getConfigPath, readConfigDiagnostics, resolveEnvValue } from "../config"; +import { readPid } from "../config/process-state"; import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; diff --git a/src/cli/index.ts b/src/cli/index.ts index 1b2c6b5668..1182c74474 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,9 @@ import { codexAutoStartEnabled, getConfigDir, loadConfig, + saveConfig, +} from "../config"; +import { readPid, readPidFileValue, readRuntimePort, @@ -19,10 +22,9 @@ import { removePidIfValueIs, removeRuntimePort, removeRuntimePortIfPidIs, - saveConfig, writePid, writeRuntimePort, -} from "../config"; +} from "../config/process-state"; import { collectStatus } from "./status"; import { diff --git a/src/cli/status.ts b/src/cli/status.ts index b3eba7a1c4..9c9f577e0e 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,5 +1,6 @@ import { durableBunRuntime } from "../lib/bun-runtime"; -import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config"; +import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config"; +import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; diff --git a/src/cli/system-restart-client.ts b/src/cli/system-restart-client.ts index b059f07076..fbc7c4548f 100644 --- a/src/cli/system-restart-client.ts +++ b/src/cli/system-restart-client.ts @@ -1,4 +1,4 @@ -import { readRuntimePort } from "../config"; +import { readRuntimePort } from "../config/process-state"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, diff --git a/src/config.ts b/src/config.ts index dcf34313a4..13d2be2448 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,6 @@ -import { execFileSync } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; @@ -42,18 +40,12 @@ import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath, - hardenSecretPathAsync, windowsSecretAclApplies, } from "./lib/windows-secret-acl"; import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; -import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; -import { - resolveTrustedWindowsPowerShellExe, - resolveTrustedWindowsSystemDirectory, -} from "./lib/windows-elevation"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { isWirePinnedModel, @@ -94,244 +86,49 @@ import { MIN_APP_OWNED_MEMORY_BUDGET_MB, } from "./lib/app-owned-memory"; import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; - -let _atomicSeq = 0; - -// The Windows-tolerant replace lives in lib/windows-atomic-replace: config-ownership -// is one of its callers and this module already imports config-ownership, so -// exporting it from here would close an import cycle. Re-exported because these -// names are part of this module's public surface and its callers. -export type { AtomicRenameIO } from "./lib/windows-atomic-replace"; -export { renameAtomicFile } from "./lib/windows-atomic-replace"; -import { renameAtomicFile, renameAtomicFileAsync } from "./lib/windows-atomic-replace"; - -/** - * Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the - * proxy's own shutdown handler both restoring Codex — can never leave a half-written file. - */ -export interface AtomicWriteIO { - write: (path: string, content: string) => void; - harden: (path: string) => void; - rename: (source: string, destination: string) => void; - truncate: (path: string) => void; - unlink: (path: string) => void; -} - -export class AtomicWriteResidualTempError extends Error { - constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) { - super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options); - this.name = "AtomicWriteResidualTempError"; - } -} - -export class AtomicWriteSecretResidualError extends Error { - constructor(readonly tempPath: string, options?: ErrorOptions) { - super("Atomic config write could not scrub or remove a secret-bearing temporary file", options); - this.name = "AtomicWriteSecretResidualError"; - } -} - -function isMissingPathError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; -} - -/** - * Resolve a write target through any symlink before the temp+rename dance. - * - * rename(2) replaces a directory ENTRY. When the entry is itself a symlink - * (a dotfiles-managed `~/.codex/config.toml` -> `~/dotfiles/.codex/config.toml`, - * say), renaming a sibling temp file over it destroys the link and leaves a plain - * file behind — the repo silently stops receiving writes. Resolving first puts both - * the temp file and the rename target inside the link's real directory, so the entry - * being replaced is the real file and the symlink survives. - * - * Same-filesystem atomicity is preserved because the temp file stays beside its - * resolved target. A genuinely absent destination (not yet created) falls back to - * the literal path, which is the correct target for a first write. - * - * An EXISTING symlink that cannot be resolved — dangling because its target volume - * is unmounted, an ELOOP chain, an EACCES parent — is refused instead. Falling back - * to the literal path there would let the rename replace the link, recreating the - * exact dotfiles-divergence failure this helper exists to prevent (audit: wt4 wp2). - */ -export function resolveWriteTarget(path: string): string { - try { - return realpathSync(path); - } catch (cause) { - let entry; - try { - entry = lstatSync(path); - } catch (error) { - if (isMissingPathError(error)) return path; // no entry at all — first write - throw error; - } - if (entry.isSymbolicLink()) { - throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause }); - } - return path; - } -} - -/** - * Re-apply the real-home guard to a RESOLVED write target. - * - * Callers such as saveConfig check only their logical config dir, which passes when - * OPENCODEX_HOME points at a temp fixture. Following a symlink out of that fixture - * would land on the protected home the caller's own check just cleared, so the guard - * has to run again on wherever the write actually terminates. Inert in production, - * where the guard is disarmed. - */ -function assertResolvedTargetAllowed(path: string, target: string): void { - // The file itself may resolve literally while its PARENT is a symlink out - // of the fixture (a first write beneath a symlinked config dir). Guard the - // directory the write actually lands in either way. - if (target === path) { - let realParent: string; - try { - realParent = realpathSync(dirname(target)); - } catch { - return; // unresolvable parent: resolveWriteTarget already owns that refusal - } - if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent); - return; - } - assertNotRealHomeUnderTest(dirname(target)); -} - -export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = { - write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), - harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - // Timeout memo keyed by the stable destination (matches the async writer): - // a failed temp harden must not mint a new unique-temp key on every write. - if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path }); - }, - rename: renameAtomicFile, - truncate: target => truncateSync(target, 0), - unlink: unlinkSync, -}): void { - recordOwnedConfigPath(resolveConfigDir(), path); - const target = resolveWriteTarget(path); - assertResolvedTargetAllowed(path, target); - const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`; - let hardened = false; - try { - io.write(tmp, content); - io.harden(tmp); - hardened = true; - io.rename(tmp, target); - forgetEphemeralSecretPath(tmp); - } catch (cause) { - let scrubbed = false; - try { - io.truncate(tmp); - scrubbed = true; - } catch (error) { - if (isMissingPathError(error)) scrubbed = true; - else { - try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } - } - } - let removed = false; - try { - io.unlink(tmp); - removed = true; - } catch (error) { - if (isMissingPathError(error)) removed = true; - else { - try { io.unlink(tmp); removed = true; } - catch (retryError) { if (isMissingPathError(retryError)) removed = true; } - } - } - if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); - if (!removed && !hardened) { - try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ } - } - if (removed) forgetEphemeralSecretPath(tmp); - if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); - throw cause; - } -} - -/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */ -export interface AtomicWriteAsyncIO { - write: (path: string, content: string) => void | Promise; - harden: (path: string) => void | Promise; - rename: (source: string, destination: string) => void | Promise; - truncate: (path: string) => void | Promise; - unlink: (path: string) => void | Promise; -} - -/** Test-only crash seam. Production callers leave this undefined. */ -export interface AtomicWriteAsyncTestSeam { - afterTempWrite?: (tempPath: string) => void | Promise; -} - -/** - * Async atomic write (#612): same temp+harden+rename and residual-temp policy as - * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed - * by the final destination path (not the unique temp, not the parent directory). - */ -export async function atomicWriteFileAsync( - path: string, - content: string, - io?: AtomicWriteAsyncIO, - testSeam?: AtomicWriteAsyncTestSeam, -): Promise { - const effective: AtomicWriteAsyncIO = io ?? { - write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), - harden: async target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - if (process.platform === "win32") { - await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path }); - } - }, - rename: renameAtomicFileAsync, - truncate: target => truncateSync(target, 0), - unlink: unlinkSync, - }; - const target = resolveWriteTarget(path); - assertResolvedTargetAllowed(path, target); - const tmp = `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`; - let hardened = false; - try { - await effective.write(tmp, content); - await testSeam?.afterTempWrite?.(tmp); - await effective.harden(tmp); - hardened = true; - await effective.rename(tmp, target); - forgetEphemeralSecretPath(tmp); - } catch (cause) { - let scrubbed = false; - try { - await effective.truncate(tmp); - scrubbed = true; - } catch (error) { - if (isMissingPathError(error)) scrubbed = true; - else { - try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } - } - } - let removed = false; - try { - await effective.unlink(tmp); - removed = true; - } catch (error) { - if (isMissingPathError(error)) removed = true; - else { - try { await effective.unlink(tmp); removed = true; } - catch (retryError) { if (isMissingPathError(retryError)) removed = true; } - } - } - if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); - if (!removed && !hardened) { - try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ } - } - if (removed) forgetEphemeralSecretPath(tmp); - if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); - throw cause; - } -} +import { + atomicWriteFile, + isMissingPathError, + nextAtomicTempSequence, +} from "./config/atomic-write"; +export { + AtomicWriteResidualTempError, + AtomicWriteSecretResidualError, + atomicWriteFile, + atomicWriteFileAsync, + renameAtomicFile, + resolveWriteTarget, + type AtomicRenameIO, + type AtomicWriteAsyncIO, + type AtomicWriteAsyncTestSeam, + type AtomicWriteIO, +} from "./config/atomic-write"; +import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +export { + getPidPath, + getRuntimePortPath, + isOcxStartCommandLine, + ocxStartProcessCacheSizeForTests, + parsePidFile, + readAlivePid, + readPid, + readPidFileValue, + readRuntimePort, + removePid, + removePidIfValueIs, + removeRuntimePort, + removeRuntimePortIfPidIs, + setOcxStartProcessCacheForTests, + setOcxStartProcessProbeForTests, + setProcessCommandLineExecForTests, + setProcessCommandLinePlatformForTests, + sweepDeadOcxStartProcessCache, + verifyPidIdentity, + writePid, + writeRuntimePort, + type RuntimePortState, +} from "./config/process-state"; export class OpenAiTierBackupCleanupError extends Error { constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } @@ -452,7 +249,7 @@ export function backupConfigBeforeOpenAiTierMigration( return "reused"; } } - const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`; + const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; let published = false; let cleanupAttempted = false; @@ -596,39 +393,6 @@ export function preserveOpenAiTierRollbackSnapshot( throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" }); } -/** - * Expand a leading `~` to the home directory in user-supplied paths - * (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it). - * `~user` and `%VAR%`/`$VAR` forms pass through untouched — those belong to the shell. - */ -export function expandUserPath(raw: string): string { - if (raw === "~") return homedir(); - if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2)); - return raw; -} - -let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null; - -function resolveConfigDir(): string { - const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined; - if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path; - const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex"); - resolvedConfigDirCache = { raw, path }; - return path; -} - -function resolveConfigPath(): string { - return join(resolveConfigDir(), "config.json"); -} - -function resolvePidPath(): string { - return join(resolveConfigDir(), "ocx.pid"); -} - -function resolveRuntimePortPath(): string { - return join(resolveConfigDir(), "runtime-port.json"); -} - const warnedConfigFallbacks = new Set(); const warnedInheritedFastWireConflicts = new Set(); let lastWarningReconciledGeneration = 0; @@ -1652,35 +1416,6 @@ const configSchema = z.object({ */ export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4-mini"]; -export function getConfigDir(): string { - return resolveConfigDir(); -} - -export function getConfigPath(): string { - return resolveConfigPath(); -} - -export function getPidPath(): string { - return resolvePidPath(); -} - -export function getRuntimePortPath(): string { - return resolveRuntimePortPath(); -} - -export function hardenConfigDir(): void { - const dir = getConfigDir(); - // The guard runs BEFORE any mutation: refusing the write after chmod/ACL - // would already have changed the protected directory (review round 2). - assertNotRealHomeUnderTest(dir); - if (existsSync(dir)) { - try { chmodSync(dir, 0o700); } catch { /* best-effort */ } - if (process.platform === "win32") { - hardenSecretDir(dir, { required: false }); - } - } -} - export function hardenExistingSecret(path: string): void { if (existsSync(path)) { try { chmodSync(path, 0o600); } catch { /* best-effort */ } @@ -3443,91 +3178,6 @@ export function applyProxyEnv(config: OcxConfig): void { process.env.NO_PROXY = entries.join(","); } -export function writePid(pid: number): void { - const dir = getConfigDir(); - // Guard before ANY directory mutation (mkdir or chmod), not just the write. - assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } else { - hardenConfigDir(); - } - atomicWriteFile(getPidPath(), String(pid)); -} - -export type RuntimePortState = { - pid: number; - port: number; - hostname?: string; - /** Per-process proof key; protected by the config directory and never served. */ - attestationSecret?: string; -}; - -function isValidRuntimePortState(value: unknown): value is RuntimePortState { - if (!value || typeof value !== "object") return false; - const state = value as Record; - const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; - const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret); - return Number.isSafeInteger(state.pid) - && Number(state.pid) > 0 - && Number.isInteger(state.port) - && Number(state.port) > 0 - && Number(state.port) <= 65535 - && hostnameOk - && attestationOk; -} - -export function writeRuntimePort(state: RuntimePortState): void { - const dir = getConfigDir(); - // Guard before ANY directory mutation (mkdir or chmod), not just the write. - assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } else { - hardenConfigDir(); - } - atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n"); -} - -export function readPid(): number | null { - const pidPath = getPidPath(); - if (!existsSync(pidPath)) return null; - try { - const raw = readFileSync(pidPath, "utf-8").trim(); - const pid = parsePidFile(raw); - if (pid === null) return null; - try { - process.kill(pid, 0); - return isLikelyOcxStartProcess(pid) ? pid : null; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "EPERM") { - return isLikelyOcxStartProcess(pid) ? pid : null; - } - return null; - } - } catch { - return null; - } -} - -export function readRuntimePort(expectedPid?: number): RuntimePortState | null { - try { - const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8")); - if (!isValidRuntimePortState(parsed)) return null; - if (expectedPid !== undefined && parsed.pid !== expectedPid) return null; - return parsed; - } catch { - return null; - } -} - -export function removePid(expectedPid?: number): void { - if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return; - try { - unlinkSync(getPidPath()); - } catch { /* ignore */ } -} - function warnConfigRepaired(configPath: string, error: z.ZodError): void { if (warnedConfigFallbacks.has(configPath)) return; warnedConfigFallbacks.add(configPath); @@ -3723,233 +3373,6 @@ function warnDroppedConfigSections(configPath: string, dropped: string[], issues ); } -export function readPidFileValue(): number | null { - try { - return parsePidFile(readFileSync(getPidPath(), "utf-8")); - } catch { - return null; - } -} - -export function removeRuntimePort(expectedPid?: number): void { - if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return; - try { - unlinkSync(getRuntimePortPath()); - } catch { /* ignore */ } -} - -/** - * Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content - * still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can - * write fresh records mid-probe; an unconditional purge would erase the new proxy's state. - */ -export function removePidIfValueIs(snapshot: number | null): void { - if (!existsSync(getPidPath())) return; - if (readPidFileValue() !== snapshot) return; - try { - unlinkSync(getPidPath()); - } catch { /* ignore */ } -} - -export function removeRuntimePortIfPidIs(snapshotPid: number | null): void { - const current = readRuntimePort(); - if ((current?.pid ?? null) !== snapshotPid) return; - try { - unlinkSync(getRuntimePortPath()); - } catch { /* ignore */ } -} - -export function parsePidFile(raw: string): number | null { - const trimmed = raw.trim(); - if (!/^\d+$/.test(trimmed)) return null; - const pid = Number.parseInt(trimmed, 10); - return Number.isSafeInteger(pid) && pid > 0 ? pid : null; -} - -export function isOcxStartCommandLine(commandLine: string): boolean { - const normalized = commandLine.toLowerCase().replace(/\\/g, "/"); - // "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current. - // `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during - // `npm install -g` — a Windows service wrapper can respawn from that temp tree - // mid-update, and must still count as ocx for port reclaim. - const hasOcxEntrypoint = normalized.includes("src/cli.ts") - || normalized.includes("src/cli/index.ts") - || normalized.includes("@bitkyc08/opencodex") - || /@bitkyc08\/\.opencodex-/.test(normalized) - || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized); - return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized); -} - -/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */ -const ocxStartProcessCache = new Map(); -let ocxStartProcessSweepCursor = 0; -let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); }; - -export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void { - ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); }); -} - -export function setOcxStartProcessCacheForTests(entries: Iterable): void { - ocxStartProcessCache.clear(); - for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value); - ocxStartProcessSweepCursor = 0; -} - -export function sweepDeadOcxStartProcessCache(maxProbes = 64): number { - const pids: number[] = []; - let removed = 0; - for (const pid of ocxStartProcessCache.keys()) { - if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid); - else if (ocxStartProcessCache.delete(pid)) removed += 1; - } - if (pids.length === 0 || maxProbes <= 0) { - ocxStartProcessSweepCursor = 0; - return removed; - } - const probeCount = Math.min(Math.floor(maxProbes), pids.length); - const start = ocxStartProcessSweepCursor % pids.length; - for (let offset = 0; offset < probeCount; offset += 1) { - const pid = pids[(start + offset) % pids.length]!; - try { - ocxStartProcessProbe(pid); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue; - if (ocxStartProcessCache.delete(pid)) removed += 1; - } - } - ocxStartProcessSweepCursor = (start + probeCount) % pids.length; - return removed; -} - -export function ocxStartProcessCacheSizeForTests(): number { - return ocxStartProcessCache.size; -} - -function isLikelyOcxStartProcess(pid: number): boolean { - const cached = ocxStartProcessCache.get(pid); - if (cached !== undefined) return cached; - const commandLine = readProcessCommandLine(pid); - if (commandLine === undefined) return false; - const ok = isOcxStartCommandLine(commandLine); - ocxStartProcessCache.set(pid, ok); - return ok; -} - -/** - * Alive pid from the pid file without the expensive Windows command-line probe. - * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy. - * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline. - */ -export function readAlivePid(): number | null { - const pid = readPidFileValue(); - if (pid === null) return null; - try { - process.kill(pid, 0); - return pid; - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code === "EPERM") return pid; - return null; - } -} - -/** - * Full identity check of a KNOWN candidate pid (alive + ocx-start command line). - * Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid - * handed to a destructive caller must pass this check — and must equal the candidate - * it was asked about, so a pidfile rewrite between discovery and verification can - * never swap in a different process (TOCTOU guard). - */ -export function verifyPidIdentity(candidatePid: number): number | null { - try { - process.kill(candidatePid, 0); - } catch (e: unknown) { - if ((e as NodeJS.ErrnoException).code !== "EPERM") return null; - } - return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null; -} - -type ProcessCommandLineExec = ( - executable: string, - args: string[], - options: { - encoding: BufferEncoding; - stdio: ["ignore", "pipe", "ignore"]; - timeout: number; - windowsHide: boolean; - }, -) => string; - -const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) => - execFileSync(executable, args, options); -let processCommandLineExec = defaultProcessCommandLineExec; -let processCommandLinePlatformForTests: NodeJS.Platform | null = null; - -/** Test-only seam for verifying the exact system executable selected by pid identity probes. */ -export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void { - processCommandLineExec = next ?? defaultProcessCommandLineExec; -} - -/** Test-only seam so cross-platform tests do not mutate process.platform. */ -export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void { - processCommandLinePlatformForTests = next; -} - -function readProcessCommandLine(pid: number): string | undefined { - if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; - const platform = processCommandLinePlatformForTests ?? process.platform; - try { - if (platform === "linux") { - try { - const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8"); - const value = output.replace(/\0/g, " ").trim(); - if (value) return value; - } catch { - /* procfs unavailable — use the fixed ps fallback below */ - } - } - if (platform === "win32") { - // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash. - // Fall back to PowerShell when WMIC is absent (newer Windows images). - const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe"); - try { - const output = processCommandLineExec(wmic, [ - "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE", - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); - const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, "")); - const value = match?.[1]?.trim(); - if (value) return value; - } catch { - /* WMIC missing or failed — fall through */ - } - const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [ - "-NoProfile", - "-NoLogo", - "-NonInteractive", - "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); - return output.trim() || undefined; - } - for (const ps of ["/bin/ps", "/usr/bin/ps"]) { - try { - const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 1000, - windowsHide: true, - }); - const value = output.trim(); - if (value) return value; - } catch { - /* try the other fixed system path */ - } - } - return undefined; - } catch { - return undefined; - } -} - function warnAndBackupInvalidConfig(configPath: string, error: unknown): void { if (warnedConfigFallbacks.has(configPath)) return; warnedConfigFallbacks.add(configPath); diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts new file mode 100644 index 0000000000..b7e204580b --- /dev/null +++ b/src/config/atomic-write.ts @@ -0,0 +1,219 @@ +import { + chmodSync, + lstatSync, + realpathSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { + forgetEphemeralSecretPath, + hardenSecretPath, + hardenSecretPathAsync, +} from "../lib/windows-secret-acl"; +import { + renameAtomicFile, + renameAtomicFileAsync, +} from "../lib/windows-atomic-replace"; +import { getConfigDir } from "./paths"; + +let atomicSequence = 0; + +/** Shared process-wide suffix source for config-owned atomic sibling files. */ +export function nextAtomicTempSequence(): number { + return ++atomicSequence; +} +/** Internal error classifier shared by config backup and atomic-write paths. */ +export function isMissingPathError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; +} + +export type { AtomicRenameIO } from "../lib/windows-atomic-replace"; +export { renameAtomicFile } from "../lib/windows-atomic-replace"; + +export interface AtomicWriteIO { + write: (path: string, content: string) => void; + harden: (path: string) => void; + rename: (source: string, destination: string) => void; + truncate: (path: string) => void; + unlink: (path: string) => void; +} + +export class AtomicWriteResidualTempError extends Error { + constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) { + super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options); + this.name = "AtomicWriteResidualTempError"; + } +} + +export class AtomicWriteSecretResidualError extends Error { + constructor(readonly tempPath: string, options?: ErrorOptions) { + super("Atomic config write could not scrub or remove a secret-bearing temporary file", options); + this.name = "AtomicWriteSecretResidualError"; + } +} + +/** + * Resolve a write target through any symlink before the temp+rename dance so + * dotfiles-managed links survive an atomic replacement. + */ +export function resolveWriteTarget(path: string): string { + try { + return realpathSync(path); + } catch (cause) { + let entry; + try { + entry = lstatSync(path); + } catch (error) { + if (isMissingPathError(error)) return path; + throw error; + } + if (entry.isSymbolicLink()) { + throw new Error(`refusing to replace unresolvable symlinked write target: ${path}`, { cause }); + } + return path; + } +} + +function assertResolvedTargetAllowed(path: string, target: string): void { + if (target === path) { + let realParent: string; + try { + realParent = realpathSync(dirname(target)); + } catch { + return; + } + if (realParent !== dirname(target)) assertNotRealHomeUnderTest(realParent); + return; + } + assertNotRealHomeUnderTest(dirname(target)); +} + +export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO = { + write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), + harden: target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: path }); + }, + rename: renameAtomicFile, + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, +}): void { + recordOwnedConfigPath(getConfigDir(), path); + const target = resolveWriteTarget(path); + assertResolvedTargetAllowed(path, target); + const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let hardened = false; + try { + io.write(tmp, content); + io.harden(tmp); + hardened = true; + io.rename(tmp, target); + forgetEphemeralSecretPath(tmp); + } catch (cause) { + let scrubbed = false; + try { + io.truncate(tmp); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { io.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + io.unlink(tmp); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { io.unlink(tmp); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); + if (!removed && !hardened) { + try { io.harden(tmp); hardened = true; } catch { /* reported below */ } + } + if (removed) forgetEphemeralSecretPath(tmp); + if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); + throw cause; + } +} + +export interface AtomicWriteAsyncIO { + write: (path: string, content: string) => void | Promise; + harden: (path: string) => void | Promise; + rename: (source: string, destination: string) => void | Promise; + truncate: (path: string) => void | Promise; + unlink: (path: string) => void | Promise; +} + +export interface AtomicWriteAsyncTestSeam { + afterTempWrite?: (tempPath: string) => void | Promise; +} + +export async function atomicWriteFileAsync( + path: string, + content: string, + io?: AtomicWriteAsyncIO, + testSeam?: AtomicWriteAsyncTestSeam, +): Promise { + const effective: AtomicWriteAsyncIO = io ?? { + write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), + harden: async target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") { + await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path }); + } + }, + rename: renameAtomicFileAsync, + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + }; + const target = resolveWriteTarget(path); + assertResolvedTargetAllowed(path, target); + const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let hardened = false; + try { + await effective.write(tmp, content); + await testSeam?.afterTempWrite?.(tmp); + await effective.harden(tmp); + hardened = true; + await effective.rename(tmp, target); + forgetEphemeralSecretPath(tmp); + } catch (cause) { + let scrubbed = false; + try { + await effective.truncate(tmp); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + await effective.unlink(tmp); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { await effective.unlink(tmp); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); + if (!removed && !hardened) { + try { await effective.harden(tmp); hardened = true; } catch { /* reported below */ } + } + if (removed) forgetEphemeralSecretPath(tmp); + if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); + throw cause; + } +} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 0000000000..b8b494ecd7 --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,40 @@ +import { chmodSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { hardenSecretDir } from "../lib/windows-secret-acl"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; + +/** + * Expand a leading `~` in user-supplied paths without interpreting shell + * variables or `~user` forms that belong to the caller's shell. + */ +export function expandUserPath(raw: string): string { + if (raw === "~") return homedir(); + if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2)); + return raw; +} +let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null; + +export function getConfigDir(): string { + const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined; + if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path; + const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex"); + resolvedConfigDirCache = { raw, path }; + return path; +} + +export function getConfigPath(): string { + return join(getConfigDir(), "config.json"); +} + +export function hardenConfigDir(): void { + const dir = getConfigDir(); + // The guard runs before any mutation: refusing after chmod/ACL would already + // have changed the protected directory used by the test-home boundary. + assertNotRealHomeUnderTest(dir); + if (!existsSync(dir)) return; + try { chmodSync(dir, 0o700); } catch { /* best-effort */ } + if (process.platform === "win32") { + hardenSecretDir(dir, { required: false }); + } +} diff --git a/src/config/process-state.ts b/src/config/process-state.ts new file mode 100644 index 0000000000..3a516fecd2 --- /dev/null +++ b/src/config/process-state.ts @@ -0,0 +1,308 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { isLocalAttestationSecret } from "../lib/local-management-attestation"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { + resolveTrustedWindowsPowerShellExe, + resolveTrustedWindowsSystemDirectory, +} from "../lib/windows-elevation"; +import { atomicWriteFile } from "./atomic-write"; +import { getConfigDir, hardenConfigDir } from "./paths"; + +export function getPidPath(): string { + return join(getConfigDir(), "ocx.pid"); +} + +export function getRuntimePortPath(): string { + return join(getConfigDir(), "runtime-port.json"); +} + +function ensureProcessStateDir(): void { + const dir = getConfigDir(); + assertNotRealHomeUnderTest(dir); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + else hardenConfigDir(); +} + +export function writePid(pid: number): void { + ensureProcessStateDir(); + atomicWriteFile(getPidPath(), String(pid)); +} + +export type RuntimePortState = { + pid: number; + port: number; + hostname?: string; + /** Per-process proof key; protected by the config directory and never served. */ + attestationSecret?: string; +}; + +function isValidRuntimePortState(value: unknown): value is RuntimePortState { + if (!value || typeof value !== "object") return false; + const state = value as Record; + const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; + const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret); + return Number.isSafeInteger(state.pid) + && Number(state.pid) > 0 + && Number.isInteger(state.port) + && Number(state.port) > 0 + && Number(state.port) <= 65535 + && hostnameOk + && attestationOk; +} + +export function writeRuntimePort(state: RuntimePortState): void { + ensureProcessStateDir(); + atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n"); +} + +export function parsePidFile(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const pid = Number.parseInt(trimmed, 10); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +export function readPidFileValue(): number | null { + try { + return parsePidFile(readFileSync(getPidPath(), "utf-8")); + } catch { + return null; + } +} + +export function readRuntimePort(expectedPid?: number): RuntimePortState | null { + try { + const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8")); + if (!isValidRuntimePortState(parsed)) return null; + if (expectedPid !== undefined && parsed.pid !== expectedPid) return null; + return parsed; + } catch { + return null; + } +} + +export function removePid(expectedPid?: number): void { + if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return; + try { unlinkSync(getPidPath()); } catch { /* ignore */ } +} + +export function removeRuntimePort(expectedPid?: number): void { + if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return; + try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ } +} + +/** + * Snapshot-guarded stale-state purge. A replacement `ocx start` can publish a + * fresh record while a liveness probe is in flight, so deletion is authorized + * only by the exact value observed before that probe. + */ +export function removePidIfValueIs(snapshot: number | null): void { + if (!existsSync(getPidPath())) return; + if (readPidFileValue() !== snapshot) return; + try { unlinkSync(getPidPath()); } catch { /* ignore */ } +} + +export function removeRuntimePortIfPidIs(snapshotPid: number | null): void { + const current = readRuntimePort(); + if ((current?.pid ?? null) !== snapshotPid) return; + try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ } +} + +export function isOcxStartCommandLine(commandLine: string): boolean { + const normalized = commandLine.toLowerCase().replace(/\\/g, "/"); + // Keep legacy source launches and npm's in-place Windows rename recognizable: + // a service wrapper may respawn from `.opencodex-*` during a global update. + const hasOcxEntrypoint = normalized.includes("src/cli.ts") + || normalized.includes("src/cli/index.ts") + || normalized.includes("@bitkyc08/opencodex") + || /@bitkyc08\/\.opencodex-/.test(normalized) + || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized); + return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized); +} + +/** Avoid spawning WMIC/PowerShell on every short liveness poll. */ +const ocxStartProcessCache = new Map(); +let ocxStartProcessSweepCursor = 0; +let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); }; + +export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void { + ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); }); +} + +export function setOcxStartProcessCacheForTests(entries: Iterable): void { + ocxStartProcessCache.clear(); + for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value); + ocxStartProcessSweepCursor = 0; +} + +export function sweepDeadOcxStartProcessCache(maxProbes = 64): number { + const pids: number[] = []; + let removed = 0; + for (const pid of ocxStartProcessCache.keys()) { + if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid); + else if (ocxStartProcessCache.delete(pid)) removed += 1; + } + if (pids.length === 0 || maxProbes <= 0) { + ocxStartProcessSweepCursor = 0; + return removed; + } + const probeCount = Math.min(Math.floor(maxProbes), pids.length); + const start = ocxStartProcessSweepCursor % pids.length; + for (let offset = 0; offset < probeCount; offset += 1) { + const pid = pids[(start + offset) % pids.length]!; + try { + ocxStartProcessProbe(pid); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue; + if (ocxStartProcessCache.delete(pid)) removed += 1; + } + } + ocxStartProcessSweepCursor = (start + probeCount) % pids.length; + return removed; +} + +export function ocxStartProcessCacheSizeForTests(): number { + return ocxStartProcessCache.size; +} + +function isLikelyOcxStartProcess(pid: number): boolean { + const cached = ocxStartProcessCache.get(pid); + if (cached !== undefined) return cached; + const commandLine = readProcessCommandLine(pid); + if (commandLine === undefined) return false; + const ok = isOcxStartCommandLine(commandLine); + ocxStartProcessCache.set(pid, ok); + return ok; +} + +export function readPid(): number | null { + const pidPath = getPidPath(); + if (!existsSync(pidPath)) return null; + try { + const raw = readFileSync(pidPath, "utf-8").trim(); + const pid = parsePidFile(raw); + if (pid === null) return null; + try { + process.kill(pid, 0); + return isLikelyOcxStartProcess(pid) ? pid : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return isLikelyOcxStartProcess(pid) ? pid : null; + } + return null; + } + } catch { + return null; + } +} + +/** + * Cheap non-destructive liveness check. Destructive callers must additionally + * call `verifyPidIdentity()` before acting on the returned PID. + */ +export function readAlivePid(): number | null { + const pid = readPidFileValue(); + if (pid === null) return null; + try { + process.kill(pid, 0); + return pid; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") return pid; + return null; + } +} + +/** + * Full identity check for a known candidate. The returned PID must equal the + * candidate, preventing a pidfile rewrite from swapping in another process. + */ +export function verifyPidIdentity(candidatePid: number): number | null { + try { + process.kill(candidatePid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EPERM") return null; + } + return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null; +} + +type ProcessCommandLineExec = ( + executable: string, + args: string[], + options: { + encoding: BufferEncoding; + stdio: ["ignore", "pipe", "ignore"]; + timeout: number; + windowsHide: boolean; + }, +) => string; + +const defaultProcessCommandLineExec: ProcessCommandLineExec = (executable, args, options) => + execFileSync(executable, args, options); +let processCommandLineExec = defaultProcessCommandLineExec; +let processCommandLinePlatformForTests: NodeJS.Platform | null = null; + +export function setProcessCommandLineExecForTests(next: ProcessCommandLineExec | null): void { + processCommandLineExec = next ?? defaultProcessCommandLineExec; +} + +export function setProcessCommandLinePlatformForTests(next: NodeJS.Platform | null): void { + processCommandLinePlatformForTests = next; +} + +function readProcessCommandLine(pid: number): string | undefined { + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; + const platform = processCommandLinePlatformForTests ?? process.platform; + try { + if (platform === "linux") { + try { + const output = readFileSync(`/proc/${pid}/cmdline`, "utf-8"); + const value = output.replace(/\0/g, " ").trim(); + if (value) return value; + } catch { + /* procfs unavailable — use the fixed ps fallback below */ + } + } + if (platform === "win32") { + // WMIC is the fast path. Newer Windows images may omit it, so fall back to + // the trusted fixed PowerShell binary without consulting PATH or env roots. + const wmic = join(resolveTrustedWindowsSystemDirectory(), "wbem", "WMIC.exe"); + try { + const output = processCommandLineExec(wmic, [ + "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE", + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); + const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, "")); + const value = match?.[1]?.trim(); + if (value) return value; + } catch { + /* WMIC missing or failed — fall through */ + } + const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", + "-NoLogo", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); + return output.trim() || undefined; + } + for (const ps of ["/bin/ps", "/usr/bin/ps"]) { + try { + const output = processCommandLineExec(ps, ["-p", String(pid), "-o", "command="], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1000, + windowsHide: true, + }); + const value = output.trim(); + if (value) return value; + } catch { + /* try the other fixed system path */ + } + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 8f60e05e67..6c0f7082f9 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { loadConfig, readRuntimePort } from "../config"; +import { loadConfig } from "../config"; +import { readRuntimePort } from "../config/process-state"; import { configuredAdminToken } from "./admin-secrets"; export function isProcessAlive(pid: number): boolean { diff --git a/src/oauth/health.ts b/src/oauth/health.ts index e0cf825563..4c997c47cc 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -3,7 +3,7 @@ import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing"; import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; -import { readRuntimePort } from "../config"; +import { readRuntimePort } from "../config/process-state"; import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability"; import { maskAccountId } from "../lib/privacy"; import { findLiveProxy } from "../server/proxy-liveness"; diff --git a/src/server/local-management-read-client.ts b/src/server/local-management-read-client.ts index 50629090db..e0d6489043 100644 --- a/src/server/local-management-read-client.ts +++ b/src/server/local-management-read-client.ts @@ -1,4 +1,4 @@ -import { readRuntimePort, type RuntimePortState } from "../config"; +import { readRuntimePort, type RuntimePortState } from "../config/process-state"; import { createLocalAttestationChallenge } from "../lib/local-management-attestation"; import { LOCAL_MANAGEMENT_CAPABILITY_HEADER, diff --git a/src/server/local-provider-reload-client.ts b/src/server/local-provider-reload-client.ts index 63e51cd14e..35e8f6fdfb 100644 --- a/src/server/local-provider-reload-client.ts +++ b/src/server/local-provider-reload-client.ts @@ -1,4 +1,4 @@ -import { readRuntimePort, type RuntimePortState } from "../config"; +import { readRuntimePort, type RuntimePortState } from "../config/process-state"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 28e326fda9..b1cbb8cd44 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -148,7 +148,7 @@ function runGrokApplyFlight(): Promise { flight.promise = (grokApplyTestHooks?.run ?? (async () => { const [{ syncGrokConfig }, { readRuntimePort }] = await Promise.all([ import("../../grok/sync"), - import("../../config"), + import("../../config/process-state"), ]); const currentConfig = loadConfig(); const runtime = readRuntimePort(process.pid); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 0e7a0c8db6..e54cb5a3c0 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -490,7 +490,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { describe("config.ts – sync writer timeout keying (#840 refinement)", () => { test("the production sync harden keys timeouts by destination", () => { - const source = readFileSync(join(import.meta.dir, "..", "src", "config.ts"), "utf-8"); + const source = readFileSync(join(import.meta.dir, "..", "src", "config", "atomic-write.ts"), "utf-8"); expect(source).toContain("hardenSecretPath(target, { required: true, timeoutMemoKey: path })"); }); diff --git a/tests/process-state.test.ts b/tests/process-state.test.ts new file mode 100644 index 0000000000..243b0154bd --- /dev/null +++ b/tests/process-state.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import * as configFacade from "../src/config"; +import { + getPidPath, + getRuntimePortPath, + isOcxStartCommandLine, + ocxStartProcessCacheSizeForTests, + parsePidFile, + readPid, + readRuntimePort, + removePid, + removeRuntimePort, + setOcxStartProcessCacheForTests, + setProcessCommandLineExecForTests, + setProcessCommandLinePlatformForTests, + writePid, + writeRuntimePort, +} from "../src/config/process-state"; +import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; + +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-process-state-")); + process.env.OPENCODEX_HOME = testDir; + setOcxStartProcessCacheForTests([]); +}); + +afterEach(() => { + setProcessCommandLineExecForTests(null); + setProcessCommandLinePlatformForTests(null); + setTrustedWindowsSystemDirectoryResolverForTests(null); + setOcxStartProcessCacheForTests([]); + delete process.env.OPENCODEX_HOME; + if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("proxy process-state ownership", () => { + test("the process-state leaf does not import the config facade", () => { + const source = readFileSync(join(import.meta.dir, "..", "src", "config", "process-state.ts"), "utf-8"); + expect(source).not.toMatch(/from\s+["']\.\.\/config["']/); + expect(source).toContain('from "./atomic-write"'); + expect(source).toContain('from "./paths"'); + }); + + test("config.ts remains a compatibility facade for public process-state exports", () => { + expect(configFacade.getPidPath).toBe(getPidPath); + expect(configFacade.getRuntimePortPath).toBe(getRuntimePortPath); + expect(configFacade.readPid).toBe(readPid); + expect(configFacade.readRuntimePort).toBe(readRuntimePort); + }); + + test("parses pid files", () => { + expect(parsePidFile("12345")).toBe(12345); + expect(parsePidFile("0")).toBeNull(); + expect(parsePidFile("12x")).toBeNull(); + expect(parsePidFile("not-json")).toBeNull(); + }); + + test("recognizes opencodex start command lines", () => { + expect(isOcxStartCommandLine("bun run src/cli.ts start")).toBe(true); + expect(isOcxStartCommandLine('"C:/tools/bun/bin/bun.exe" "run" "src/cli/index.ts" "start"')).toBe(true); + expect(isOcxStartCommandLine("bun C:/tools/bun/install/global/node_modules/@bitkyc08/opencodex/src/cli.ts start")).toBe(true); + expect(isOcxStartCommandLine( + "bun C:/nvm/node_modules/@bitkyc08/.opencodex-1JejBqbZ/src/cli/index.ts start --port 10100", + )).toBe(true); + expect(isOcxStartCommandLine("opencodex start")).toBe(true); + expect(isOcxStartCommandLine("bun run src/cli.ts status")).toBe(false); + expect(isOcxStartCommandLine("bun test C:/work/opencodex/tests/config.test.ts")).toBe(false); + expect(isOcxStartCommandLine("notepad.exe")).toBe(false); + }); + + test("writes pid state through the shared atomic writer", () => { + writePid(process.pid); + expect(readFileSync(getPidPath(), "utf-8")).toBe(String(process.pid)); + }); + + test("pid validation never resolves ps through PATH", () => { + const attackerDir = join(testDir, "attacker-bin"); + const fakePs = join(attackerDir, "ps"); + const markerPath = `${fakePs}.executed`; + const previousPath = process.env.PATH; + const probes: string[] = []; + mkdirSync(attackerDir); + writeFileSync(fakePs, `#!/bin/sh\ntouch "$0.executed"\necho 'ocx start'\n`, { mode: 0o755 }); + + try { + setProcessCommandLinePlatformForTests("darwin"); + setProcessCommandLineExecForTests(executable => { + probes.push(executable); + throw new Error("fixed ps probe unavailable"); + }); + process.env.PATH = `${attackerDir}${delimiter}${previousPath ?? ""}`; + writePid(process.pid); + + expect(readPid()).toBeNull(); + expect(probes).toEqual(["/bin/ps", "/usr/bin/ps"]); + expect(existsSync(markerPath)).toBe(false); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + + expect(process.env.PATH).toBe(previousPath); + expect(ocxStartProcessCacheSizeForTests()).toBe(0); + }); + + test("pid validation selects only trusted Windows process probes", () => { + const previousSystemRoot = process.env.SystemRoot; + const previousWindir = process.env.WINDIR; + const trustedSystem32 = join(testDir, "trusted", "System32"); + const trustedWmic = join(trustedSystem32, "wbem", "WMIC.exe"); + const trustedPowerShell = join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"); + const attackerRoot = join(testDir, "attacker-windows"); + const calls: string[] = []; + + try { + mkdirSync(dirname(trustedPowerShell), { recursive: true }); + writeFileSync(trustedPowerShell, "", { mode: 0o755 }); + setProcessCommandLinePlatformForTests("win32"); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); + process.env.SystemRoot = attackerRoot; + process.env.WINDIR = attackerRoot; + writeFileSync(getPidPath(), String(process.pid), "utf-8"); + + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) return "CommandLine=ocx start\r\n"; + throw new Error(`unexpected process probe: ${executable}`); + }); + expect(readPid()).toBe(process.pid); + expect(calls).toEqual([trustedWmic]); + + calls.length = 0; + setOcxStartProcessCacheForTests([]); + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) throw new Error("WMIC unavailable"); + if (executable === trustedPowerShell) return "ocx start\n"; + throw new Error(`unexpected process probe: ${executable}`); + }); + expect(readPid()).toBe(process.pid); + expect(calls).toEqual([trustedWmic, trustedPowerShell]); + expect(calls.every(executable => !executable.startsWith(attackerRoot))).toBe(true); + } finally { + setOcxStartProcessCacheForTests([]); + if (previousSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = previousSystemRoot; + if (previousWindir === undefined) delete process.env.WINDIR; + else process.env.WINDIR = previousWindir; + } + + expect(ocxStartProcessCacheSizeForTests()).toBe(0); + }); + + test("removes pid state only while the expected pid still matches", () => { + writeFileSync(getPidPath(), "111", "utf-8"); + removePid(222); + expect(existsSync(getPidPath())).toBe(true); + + removePid(111); + expect(existsSync(getPidPath())).toBe(false); + }); + + test("runtime port metadata round-trips and validates the expected pid", () => { + const attestationSecret = "A".repeat(43); + writeRuntimePort({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); + + expect(readRuntimePort()).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); + expect(readRuntimePort(1234)).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); + expect(readRuntimePort(9999)).toBeNull(); + }); + + test("runtime port removal preserves newer pid state", () => { + writeRuntimePort({ pid: 1234, port: 58195 }); + removeRuntimePort(9999); + expect(existsSync(getRuntimePortPath())).toBe(true); + removeRuntimePort(1234); + expect(existsSync(getRuntimePortPath())).toBe(false); + }); + + test("invalid runtime port metadata is rejected", () => { + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 99999 }), "utf-8"); + expect(readRuntimePort()).toBeNull(); + + writeFileSync( + getRuntimePortPath(), + JSON.stringify({ pid: 1234, port: 58195, attestationSecret: "too-short" }), + "utf-8", + ); + expect(readRuntimePort()).toBeNull(); + }); +});