Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,7 @@
"self-launch-argv.test.ts": "lib",
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-auth-localhost-bind.test.ts": "server",
"server-auth.test.ts": "server",
"server-background-lifecycle.test.ts": "server",
"server-clickjacking-headers.test.ts": "server",
Expand Down Expand Up @@ -1381,6 +1382,7 @@
"server-xai-oauth-401-replay.test.ts": "server",
"server-xai-responses-streaming.test.ts": "server",
"service-ownership-compatibility.test.ts": "service",
"service-auth-qualified-localhost.test.ts": "service",
"service-ownership-handover.test.ts": "service",
"service-ownership-state.test.ts": "service",
"service-probe-docker.test.ts": "service",
Expand Down
101 changes: 99 additions & 2 deletions src/lib/service-secrets.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, unlinkSync } from "node:fs";
import { closeSync, constants, existsSync, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config";
import { atomicWriteFile } from "../config/atomic-write";
import { atomicWriteFile, atomicWriteFileNoFollow } from "../config/atomic-write";

const MAX_SERVICE_API_TOKEN_BYTES = 4096;

Expand Down Expand Up @@ -49,6 +49,103 @@ export function readServiceApiTokenState(): ServiceApiTokenState {
}
}

/**
* Validate and tighten a reused service token without applying permissions to a
* pathname that may have been replaced since validation.
*
* The token is read off the opened descriptor — never off the path a second
* time — and once it validates, it is REPUBLISHED through the no-follow atomic
* writer rather than hardened in place. Windows ACL tooling is pathname-based,
* so an in-place harden there could still land on a substituted entry; the
* republish instead replaces whatever entry sits at the path with a freshly
* hardened owner-only file holding the same token. On return the path names
* that file, which is the contract `origin: "file"` reports. On POSIX the
* opened descriptor is also fchmod'd first, so a token-bearing inode a race
* moved aside is still tightened wherever its entry ended up.
*
*
* Return contract vs `readServiceApiTokenState`: an empty or malformed token file
* reports `unsafe` here and is never written — the path-based pre-check may still
* pass the install on loopback while this writer deliberately leaves the file
* untouched. Only `absent` permits a fresh write; anything unreadable stays as-is.
*
* Callers must run this under `withConfigMutationLockSync`: client-key rotation
* replaces the token under that lock, and a republish outside it could rename a
* stale token back over a committed rotation.
*/
export function hardenReusedServiceApiToken(
validate: (token: string) => void,
): ServiceApiTokenState {
const path = serviceApiTokenFilePath();
// O_NOFOLLOW refuses a symlinked entry and O_NONBLOCK keeps a FIFO (or other
// blocking node) from stalling the open before fstat can reject it. Windows
// omits both flags, so there the descriptor is bound to its entry by the
// lstat/fstat identity comparison below.
const flags = process.platform === "win32"
? constants.O_RDONLY
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
let fd: number | undefined;
try {
fd = openSync(path, flags);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { kind: "absent" };
if (code === "ELOOP") return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
return { kind: "unsafe", reason: "service token path could not be inspected" };
}
try {
const stat = fstatSync(fd, { bigint: true });
if (!stat.isFile() || stat.size > BigInt(MAX_SERVICE_API_TOKEN_BYTES)) {
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
}
if (process.platform === "win32") {
let entry;
try {
entry = lstatSync(path, { bigint: true });
} catch {
return { kind: "unsafe", reason: "service token path could not be inspected" };
}
if (entry.isSymbolicLink() || !entry.isFile() || entry.dev !== stat.dev || entry.ino !== stat.ino) {
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
}
}
// Bound the read as well as the stat: a file that grows past the cap after
// fstat is unsafe, not something to buffer whole.
const bytes = Buffer.alloc(MAX_SERVICE_API_TOKEN_BYTES + 1);
let length = 0;
try {
while (length < bytes.length) {
const count = readSync(fd, bytes, length, bytes.length - length, null);
if (!count) break;
length += count;
}
} catch {
return { kind: "unsafe", reason: "service token file could not be read" };
}
if (length > MAX_SERVICE_API_TOKEN_BYTES) {
return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
}
const token = bytes.subarray(0, length).toString("utf8").trim();
if (!token) return { kind: "unsafe", reason: "service token file is empty" };
validate(token);
if (process.platform !== "win32") {
// Best-effort matches the previous repair behavior: the descriptor binds
// the chmod to the regular file opened above even if its directory entry
// moved, so the validated inode is never left loose under another name.
try { fchmodSync(fd, 0o600); } catch { /* best-effort */ }
}
// The descriptor's work ends here — and must: Windows refuses to rename over
// a file this process still holds open, so the republish cannot run while it
// is held.
closeSync(fd);
fd = undefined;
atomicWriteFileNoFollow(path, `${token}\n`);
return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) };
} finally {
if (fd !== undefined) closeSync(fd);
}
}

export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken {
const value = token.trim();
if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) {
Expand Down
5 changes: 3 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
const listenPort = port ?? config.port ?? 10100;
setCorsOrigin(listenPort);

// Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which
// Canonicalize an explicit "localhost" bind (including its fully-qualified spelling) to IPv4
// so it matches the injected base_url (which
// resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
// is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
// (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
const configuredHost = config.hostname?.trim();
const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
const bindHost = !configuredHost || /^localhost\.?$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;

// Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
// A port-less enabled entry is the companion form: same port as the public listener, on
Expand Down
2 changes: 1 addition & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state";
export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state";
export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, serviceCodexHomeMatchesInstall, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state";
export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs";
export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs";
export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility";
Expand Down
81 changes: 38 additions & 43 deletions src/service/guards.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { getConfigDir, loadConfig } from "../config";
import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
import { withConfigMutationLockSync } from "../config/mutation-lock";
import { hardenReusedServiceApiToken, readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
import { randomBytes } from "node:crypto";
import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
import { recordOwnedConfigPath } from "../lib/config-ownership";
import { isTestHomeGuardArmed } from "../lib/test-home-guard";
import { diagnoseService } from "./diagnostics";
import type { ServiceDiagnostic } from "./diagnostics";
import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState } from "./state";
import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState, serviceCodexHomeMatchesInstall } from "./state";
import { resolveCodexSqliteHome } from "../codex/paths";
import { isLoopbackHostname } from "../codex/loopback-target";
import { win32 } from "node:path";

/**
Expand Down Expand Up @@ -46,9 +48,7 @@ export function assertServiceEnvironmentMatchesInstall(): void {
const state = readServiceInstallState();
if (!state) return;
const actualCodexHome = currentCodexHome();
const expected = normalizePathForCompare(state.codexHome);
const actual = normalizePathForCompare(actualCodexHome);
if (expected !== actual) {
if (!serviceCodexHomeMatchesInstall(state.codexHome)) {
throw new ServiceOwnershipError(
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
Expand All @@ -73,11 +73,6 @@ export function assertServiceEnvironmentMatchesInstall(): void {
}
}

function isLoopbackHostname(hostname: string | undefined): boolean {
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
}

/**
* The `ocx` command a user should rerun for the service state they actually have.
*
Expand Down Expand Up @@ -228,42 +223,42 @@ function persistServiceApiToken(token: string): string {
* connected to a hub the same file holds that hub's issued client key, which must not be
* overwritten by a local install.
*
* Provisioning runs inside the cross-process config mutation lock: client-key rotation
* replaces `service-api-token` and records the new fingerprint under the same lock, so a
* reuse republish or a fresh write here can never interleave with a committed rotation and
* silently roll its bytes back.
*
* The PATH is logged; the value never is, and never reaches argv, a unit file or a plist.
*/
export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null {
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
if (token) {
// Last line of defence: every install/repair path funnels through here, so a
// collision cannot reach disk regardless of which caller ran (#2696).
assertNotAdminToken(token);
const path = persistServiceApiToken(token);
console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
return { path, origin: "env" };
}
if (isLoopbackHostname(loadConfig().hostname)) return null;
const existing = readServiceApiTokenState();
if (existing.kind === "present") {
// The collision check is NOT only for the env branch. A file that already holds the admin
// token -- hand-pasted before #2696, or written by the very incident this unit closes --
// was silently accepted here, so `ocx status` reported `present (file)` and the hub
// crash-looped at boot with no command pointing at the cause.
const path = serviceApiTokenFilePath();
assertNotAdminToken(existing.token, process.env, "file");
// `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well
// be group- or world-readable. Tighten it on the way through rather than claiming
// "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod
// and failing the install over it would be worse than the loose mode.
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
if (process.platform === "win32") hardenSecretPath(path, { required: false });
// No log line: repair/restart hit this on every run and an unconditional notice about a
// credential file trains operators to ignore the one that matters.
return { path, origin: "file" };
}
if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
const path = persistServiceApiToken(randomBytes(32).toString("hex"));
console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
return { path, origin: "generated" };
return withConfigMutationLockSync(() => {
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
if (token) {
// Last line of defence: every install/repair path funnels through here, so a
// collision cannot reach disk regardless of which caller ran (#2696).
assertNotAdminToken(token);
const path = persistServiceApiToken(token);
console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
return { path, origin: "env" };
}
if (isLoopbackHostname(loadConfig().hostname)) return null;
const existing = hardenReusedServiceApiToken(token => assertNotAdminToken(token, process.env, "file"));
if (existing.kind === "present") {
// The collision check is NOT only for the env branch. A file that already holds the admin
// token -- hand-pasted before #2696, or written by the very incident this unit closes --
// was silently accepted here, so `ocx status` reported `present (file)` and the hub
// crash-looped at boot with no command pointing at the cause.
const path = serviceApiTokenFilePath();
// No log line: repair/restart hit this on every run and an unconditional notice about a
// credential file trains operators to ignore the one that matters.
return { path, origin: "file" };
}
if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
const path = persistServiceApiToken(randomBytes(32).toString("hex"));
console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
return { path, origin: "generated" };
});
}

export function sh(cmd: string): string {
Expand Down
13 changes: 12 additions & 1 deletion src/service/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { homedir } from "node:os";
import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
import { expandUserPath, getConfigDir } from "../config";
import { atomicWriteFileStreamed } from "../config/atomic-write";
import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home";
import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home";
import { resolveCodexSqliteHome } from "../codex/paths";
import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime";
import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw";
Expand Down Expand Up @@ -857,6 +857,17 @@ export function serviceHomeMatches(a: string, b: string): boolean {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}

/** Accept the Linux default written by service versions predating WSL home discovery. */
export function serviceCodexHomeMatchesInstall(recordedHome: string, deps: CodexHomeDeps = {}): boolean {
const actualHome = currentCodexHome(deps);
if (serviceHomeMatches(recordedHome, actualHome)) return true;

const env = deps.env ?? process.env;
if (env.CODEX_HOME?.trim() || !isWslRuntime(deps)) return false;
const legacyDefault = join((deps.homedir ?? homedir)(), ".codex");
return serviceHomeMatches(recordedHome, legacyDefault);
}

/** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */
export function readServiceBackend(): ServiceBackend {
return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
Expand Down
5 changes: 3 additions & 2 deletions structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ treat it as destructive, not as an upgrade or restart command.
Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve
to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a
later repair or uninstall look foreign even though the service and runtime were started from the
same environment. An explicit `CODEX_HOME` remains authoritative, and existing foreign ownership
records are never migrated implicitly.
same environment. Ownership checks therefore accept that exact legacy Linux-home record when WSL
now discovers a Windows home. An explicit `CODEX_HOME` remains authoritative, and other foreign
ownership records are never migrated implicitly.

> Decision record: [ADR-0006](decisions/ADR-0006-codex-home.md)

Expand Down
Loading
Loading