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 @@ -1356,6 +1356,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 @@ -1383,6 +1384,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
18 changes: 8 additions & 10 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
let startupOwnershipStatePaths: readonly string[] | null = null;
// #2923: both synchronous startup ownership decisions keep their fresh,
// race-sensitive targeted task query. Only the expensive fallback listing is
// shared, and only while that targeted result stays byte-for-byte unchanged.
// Runtime ownership retries below intentionally omit this startup-local memo.
// #2923: retain a successful fallback listing only within the first startup
// ownership decision. A targeted query's bytes are not a Task Scheduler state
// generation, so the later race-sensitive decision must take a fresh listing.
// Runtime ownership retries below intentionally omit this startup-local memo too.
const startupWindowsTaskListingCache = createWindowsTaskListingCache();
try {
const homes = resolveServiceHomes();
Expand Down 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 Expand Up @@ -515,16 +516,14 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
// still unreadable to a browser dashboard -- which made exposing it pointless.
return withCors(workflowDecisionRefusalResponse(workflow, undefined, refusalLog), req, policy);
}
const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
if (workflow?.admitted) lease.attach(workflow.lease);
let response: Response;
try {
response = await runAdmittedBodyWork(req, policy, config.maxInboundBodyBytes, () => work(lease), refusalLog);
} catch (error) {
releaseWorkflow();
lease.release();
throw error;
}
releaseWorkflow();
if (!lease.isTransferred()) {
lease.release();
}
Expand Down Expand Up @@ -554,7 +553,6 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
deps,
startupOwnershipHomes,
startupOwnershipStatePaths,
startupWindowsTaskListingCache,
);
const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
&& startupOwnershipHomes !== null
Expand Down
8 changes: 8 additions & 0 deletions src/server/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const MAX_ACTIVE_SESSION_LANES = 64;
export const SESSION_LANE_ID_BYTES = 32;
const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
export interface ActiveTurnLease extends AdmissionLease {
attach(lease: AdmissionLease): void;
bindAbortController(ac: AbortController): void;
beginCodexAccountSelection(): CodexAccountSelectionAdmission;
isTransferred(): boolean;
Expand Down Expand Up @@ -190,10 +191,15 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
}
}
const controllers = new Set<AbortController>();
const attachedLeases = new Set<AdmissionLease>();
let active = true;
let transferred = false;
let nativeMainClaimed = false;
const lease: ActiveTurnLease = {
attach(attachedLease) {
if (!active) attachedLease.release();
else attachedLeases.add(attachedLease);
},
bindAbortController(ac) {
knownTurnControllers.add(ac);
if (!active) {
Expand Down Expand Up @@ -238,6 +244,8 @@ export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null {
if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
}
controllers.clear();
for (const attachedLease of attachedLeases) attachedLease.release();
attachedLeases.clear();
nativeMainTurns.delete(lease);
if (opaqueSessionLaneId) {
const currentRefCount = activeSessionLaneRefCounts.get(opaqueSessionLaneId);
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
Loading
Loading