Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ ocx restore # restore native Codex without stopping (alias: ocx eject)
ocx restore back # route Codex through the still-running proxy again
```

## Startup ownership could not be verified

If startup reports `Shared-client owner could not be verified` and `refusing startup`,
OpenCodex could not safely decide whether another proxy owns the shared client settings.
It stops before publishing a new runtime or changing those settings. Retry startup after
local connectivity recovers; an installed background service retries through its service manager.
A timeout does not permanently turn the new process into a secondary instance.

## Next

- [How It Works](/getting-started/how-it-works/) — what happens to each request.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"root": "tests",
"explicitShards": ["layout-clients.json"],
"explicit": { "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"owner-registry-isolation.test.ts": "config",
"codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses",
"messages-request-id-endpoint.test.ts": "claude-integration",
"anthropic-revoked-token.test.ts": "adapters/anthropic",
Expand Down
2 changes: 2 additions & 0 deletions scripts/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export function createIsolatedTestEnvironment(
HOME: root,
USERPROFILE: root,
OPENCODEX_HOME: opencodexHome,
// Pin per process for both the preload and children that inherit this environment.
OCX_OWNER_REGISTRY_DIR: join(opencodexHome, "ocx-homes"),
CODEX_HOME: codexHome,
// Client homes that otherwise default to os.homedir(). Bun keeps the home it read at
// startup, so a preload that only rewrites HOME leaves these at the developer's real
Expand Down
31 changes: 17 additions & 14 deletions src/cli/cross-home-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readClientConnectionState } from "../client/state";
import { findManagedRegion, resolveGrokHome } from "../grok/inject";
import { providerTableString } from "../codex/injected-marker";
import { isLocalAttestationSecret } from "../lib/local-management-attestation";
import { isProtectedHomeUnderTest } from "../lib/test-home-guard";
import { readOwnerRegistry } from "../config/owner-registry";
import {
classifyHealthz,
Expand Down Expand Up @@ -160,7 +161,7 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
};

const defaultHome = join(options.homeDir ?? homedir(), ".opencodex");
recordHome(defaultHome);
if (options.homeDir !== undefined || !isProtectedHomeUnderTest(defaultHome)) recordHome(defaultHome);
Comment thread
lidge-jun marked this conversation as resolved.
// The shared registry is what lets one custom home find another: every runtime
// that published a runtime record registered its home beside the shared clients.
const registry = readOwnerRegistry();
Expand Down Expand Up @@ -291,33 +292,35 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
}

/** Mark this process before any shared-client write when another home owns the clients. */
export async function markCrossHomeSibling(): Promise<boolean> {
const verdict = await findCrossHomeOwnerDetailed();
export async function markCrossHomeSibling(
options: Parameters<typeof findCrossHomeOwnerDetailed>[0] = {},
): Promise<boolean> {
const verdict = await findCrossHomeOwnerDetailed(options);
if (verdict.kind === "none") return false;
if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write.");
if (verdict.kind === "indeterminate") {
// Fail closed: an owner the reader could not verify still owns the shared
// writes. Marking the best port hint keeps every sibling gate engaged even
// while the answer stays unproven (#6198).
console.warn(
"A shared-client owner could not be verified (" + verdict.reason + "); "
+ "treating this instance as a sibling so Codex, Grok and Claude configs are left alone.",
// A missed probe is not proof of a sibling topology. Refuse before publication
// so the service can retry discovery without freezing uncertainty for its lifetime.
throw new Error(
`Shared-client owner could not be verified (${verdict.reason}); `
+ "refusing startup before any shared-client write. Retry startup after discovery recovers.",
);
}
const port = verdict.port;
markSiblingStart(port);
markSiblingStart(verdict.port);
return true;
}

/** A live proxy in this home can publish its sibling owner even while that owner is down. */
export async function markLiveHomeSibling(live: { pid: number | null; port: number }): Promise<boolean> {
export async function markLiveHomeSibling(
live: { pid: number | null; port: number },
options: Parameters<typeof findCrossHomeOwnerDetailed>[0] = {},
): Promise<boolean> {
const runtime = readRuntimePort();
if (live.pid !== null && runtime?.pid === live.pid && runtime.port === live.port
&& runtime.siblingOfPort !== undefined && runtime.siblingOfPort !== live.port) {
markSiblingStart(runtime.siblingOfPort);
return true;
}
const verdict = await findCrossHomeOwnerDetailed();
const verdict = await findCrossHomeOwnerDetailed(options);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (verdict.kind === "none" || verdict.port === live.port) return false;
if (verdict.port === null) throw new Error("Shared-client owner could not be located; refusing startup before any shared-client write.");
markSiblingStart(verdict.port);
Expand Down
39 changes: 22 additions & 17 deletions src/config/owner-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,19 @@
*/

import { createHash } from "node:crypto";
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { atomicWriteFileNoFollow } from "./atomic-write";
import { getConfigDir } from "./paths";
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard";
import { hardenSecretDir } from "../lib/windows-secret-acl";

const REGISTRY_DIR_NAME = "ocx-homes";
/**
* Test seam, not a user knob: os.homedir() resolves the passwd database on POSIX,
* so rewriting HOME inside a test process cannot move the anchor and the armed
* test-home guard would rightly refuse the write. Suites point this at their own
* fixture home; production always leaves it unset so the default home stays the
* one locator every sibling can find.
* Test seam, not a user knob. The sandbox pins this for inherited child environments;
* guarded callers without it use their selected config home. Production leaves it
* unset so the default home stays the one locator every sibling can find.
*/
const REGISTRY_DIR_ENV = "OCX_OWNER_REGISTRY_DIR";
const REGISTRY_ENTRY_SUFFIX = ".json";
Expand All @@ -60,8 +58,23 @@ function registryBaseDir(): string {
/** The shared directory the registry lives under. */
export function ownerRegistryDir(): string {
const override = process.env[REGISTRY_DIR_ENV]?.trim();
if (override) return resolve(override);
return join(registryBaseDir(), REGISTRY_DIR_NAME);
const dir = override ? resolve(override)
: join(isTestHomeGuardArmed() ? getConfigDir() : registryBaseDir(), REGISTRY_DIR_NAME);
// Bun caches the startup home, so HOME rewrites cannot isolate this anchor.
// Check reads and removals too, including an inherited override or symlink that
// points back into the protected home. An unpinned guarded caller fails closed.
if (isTestHomeGuardArmed()) {
for (let ancestor = dir; ;) {
assertNotRealHomeUnderTest(ancestor);
// Follow an existing alias before walking parents; missing children reach
// their nearest existing ancestor on the next iterations.
try { ancestor = realpathSync.native(ancestor); } catch { /* not created yet */ }
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
}
}
return dir;
}

function registryEntryPath(dir: string, home: string): string {
Expand All @@ -76,14 +89,6 @@ function registryEntryPath(dir: string, home: string): string {
export function registerOwnerRegistryHome(home: string): void {
try {
const dir = ownerRegistryDir();
// Guard every ancestor of the write target: an override that still resolves
// under the protected ~/.opencodex must not slip past the test home guard.
for (let ancestor = dir; ;) {
assertNotRealHomeUnderTest(ancestor);
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
}
mkdirSync(dir, { recursive: true, mode: 0o700 });
// This locator contains no secret, but its deterministic entry names must not
// give another local user a place to plant a destination symlink. Refuse a
Expand Down
2 changes: 1 addition & 1 deletion structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ A sibling instance is `ocx start --port <other>` while a live proxy serves the c
lease only with its own `OPENCODEX_HOME`, and still shares this Codex home, `~/.claude`, `~/.grok` and
the launchd domain with the live owner. `handleStart` marks the process through
`src/codex/sibling-start.ts` before the server binds, and the mark is one-way for the process's
lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/<hash>.json, home path only, written beside runtime-port.json publication; OCX_OWNER_REGISTRY_DIR overrides the anchor for tests only) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home, and pointers whose home no longer publishes a record are pruned before the reader's entry cap while a truncated listing fails closed as indeterminate. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and the start still takes the sibling mark, so a competing owner never rewrites shared clients on unverifiable evidence. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason
lifetime. The cross-home check follows same-home discovery and precedes journal reconciliation. It reads the default home's protected runtime record plus every home nominated by the shared owner registry, plus managed Grok and Codex loopback URLs as location hints. The registry (~/.opencodex/ocx-homes/<hash>.json, home path only, written beside runtime-port.json publication; OCX_OWNER_REGISTRY_DIR overrides the anchor for tests only) is the locator that lets one custom home find another; it lives in OpenCodex's own namespace, never inside a protected client home, and pointers whose home no longer publishes a record are pruned before the reader's entry cap while a truncated listing fails closed as indeterminate. It accepts a different process only when the listener's PID matches a record and a fresh `/healthz` challenge proves possession of its attestation secret; an unauthenticated listener at a stale managed destination is not an owner, and a sole custom-home start still syncs. When a managed destination answers but ownership cannot be decided - a live listener no registered record names, a legacy record without an attestation secret, or an unreadable transport - the verdict is indeterminate and a new start refuses before binding, runtime publication or shared-client writes. A later start retries discovery from scratch; a transient timeout never becomes a permanent sibling mark. An already-running home keeps its recorded sibling relationship during owner downtime. The mark closes `localClientSyncAllowed` in `src/codex/desired-state.ts` with its own skip reason
`sibling`, so startup sync, cache invalidation, Grok, the retained catalog writers and the native-main
lifecycle stand down (the sibling runs the no-op lifecycle, so it never contends for the owner lease;
its data-plane `auth.json` refresh still runs under the machine-wide exclusive claim). Owner-level
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ scrub-before-unlink failure path, and explicit residual-temp errors. A caller mu
`src/config/persist-unlocked.ts` and `src/config/live-reconcile.ts` follow the [publication-aware rollback contract](gui-and-management-api.md#durable-provider-patch). `src/config/live-reconcile.ts` adopts committed model discovery and disabled selectors together with their scoped live merge baselines. A later manual enable therefore removes the automatic disable instead of having a stale three-way merge restore it. Unrelated live config baselines are not advanced by that adoption.

Owner-registry publication first refuses a directory symlink, applies POSIX mode `0700`, and requires `hardenSecretDir` for the locator directory. On Windows the default secret-directory ACL grants the current owner full control before removing inherited and broad-user access, without elevated-stage reader grants. A hardening failure skips this best-effort publication before any pointer is written.

Under the armed test-home guard, `src/config/owner-registry.ts` uses the selected config home when its test override is absent and rejects protected-home destinations before reads, publication or retirement; production keeps the shared default-home registry. See [test state isolation](ops/test-sandbox-cleanup.md).
Windows hardening in the atomic writer is applied once per write, not once per harden call. Both calls stay
`required: true` and still fail the write closed, but the pre-rename call resolves through the
`src/lib/windows-secret-acl.ts` success memo: after the content write the writer re-asserts that the
Expand Down
9 changes: 9 additions & 0 deletions structure/ops/test-sandbox-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ that root. Its synchronous exit fallback leaves an undrained root for ownership-
recovery instead of blocking child cleanup with removal retries.
`tests/ci-workflows/test-sandbox-cleanup.test.ts` pins that ordering with a delayed reap.

`scripts/test.ts` pins `OCX_OWNER_REGISTRY_DIR` beneath each sandbox's OpenCodex home,
including the sandbox created by the preload. It never inherits a host registry override.
Under the armed guard, `src/config/owner-registry.ts` also resolves an absent override
under the selected `OPENCODEX_HOME`, rather than Bun's cached startup home. Its resolver
rejects protected-home ancestors before reads, registration or retirement, including explicit
overrides and symlink aliases. Production keeps the shared default-home registry.
`tests/config/owner-registry-isolation.test.ts` verifies guarded child reads, writes,
retirement and fixture switches against a foreign startup-home record, plus production behavior.

The sandbox also pins the client homes that otherwise default to `os.homedir()`:
`CLAUDE_CONFIG_DIR` and `GROK_HOME` point inside its root and are never inherited, because
Bun's `os.homedir()` keeps the home it read at startup and a preload that only rewrites HOME
Expand Down
4 changes: 2 additions & 2 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,13 @@ their own files. `src/grok/inject.ts` holds the shared canonical config-write lo

## Lifecycle

Startup catalog sync and native restore apply the [retired-native policy](catalog.md#shared-catalog).
Startup catalog sync and native restore apply the [retired-native policy](catalog.md#shared-catalog). Guarded tests isolate the owner registry used by cross-home discovery beneath their selected home, including children without a registry override; production retains the shared locator. See [test state isolation](ops/test-sandbox-cleanup.md).
`src/server/background-lifecycle.ts` registers low-quota protection for each live server, flushes pending saves before releasing its owner, binds its own event ledger to management requests, and unregisters it on failed startup or stop; with no pending save, owner release remains synchronous. The protection writer loads config persistence only when a threshold schedules a save; see [configuration](config.md#codex-pool-low-quota-protection). Codex quota processing has shared and Reserve scopes; retired model evidence is suppressed as
described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider-contract).

`ocx start` refuses a duplicate PID, starts the proxy, writes `~/.opencodex/ocx.pid` and
`runtime-port.json` through `src/config/process-state.ts`, syncs Codex config/catalog, then serves
until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. Service mode sets
until shutdown. Normal shutdown restores native Codex; a sibling instance beside a live proxy ([Codex home](codex-home.md#codex-home)) syncs and restores nothing, and `ocx stop` of a runtime whose record carries `siblingOfPort` skips the shared teardown. `src/cli/index.ts` resolves same-home ownership, then checks shared client hints through the cross-home owner helper, then reconciles the journal only when no sibling is marked. An indeterminate cross-home result refuses a new start before binding or publication, rather than persisting an unverified sibling topology; the next start performs fresh discovery. Service mode sets
`OCX_SERVICE=1`, so managed restarts do not repeatedly restore/reinject; explicit service stop and
uninstall still restore. `src/service/cli.ts` removes the service token on uninstall only when persisted client state is disconnected and no pending connect marker owns the newly issued key. `src/client/connect.ts` publishes that fingerprint marker before the key, then clears it with the connection commit or rollback under the client lifecycle and config mutation locks. Connected, invalid, or mismatched client state retains an existing token. A valid pending marker retains only its matching fingerprint; an older marker does not own a replacement service key. An absent token is reported as absent; unsafe, malformed, or unreadable markers and lock, state-read, or deletion failures leave cleanup unverified.
`src/cli/tray-proxy.ts` coordinates `ocx restart` and tray restart. A second start requires proof the prior attempt never launched or its child exited, plus confirmed absence under a fresh bounded discovery window; a missing health response alone is insufficient. After an accepted restart, polling continues through uncertain observations until its deadline and requires a new runtime PID on the same port. If the old proxy then disappears, explicit recovery starts it regardless of the Codex autostart preference; a skipped recovery fails rather than reporting success. The Windows tray startup-health probe in `src/tray/windows-tray.ps1` requests termination at its 30-second deadline without waiting on the UI thread. It retains the process through later ticks until actual exit and both redirected readers settle; replacements cannot start while that reference is held. Launch setup failures follow the same cleanup. Tray shutdown alone waits at most 500 ms before disposal.
Expand Down
Loading
Loading