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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json"],
"explicit": { "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"explicit": { "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "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",
Expand Down
15 changes: 7 additions & 8 deletions src/service/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,20 +172,19 @@ function defaultOpenCodexHome(): string {
}

export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome());
// Bun caches the startup home: parallel workers inherit the wrapper's HOME even
// after the preload gives each one a sandbox. Tests must not share that mirror,
// including serial cases that select different OPENCODEX_HOME fixtures.
return serviceStateFilesFor(opencodexHome, isTestHomeGuardArmed() ? opencodexHome : defaultOpenCodexHome());
}

export function serviceStatePaths(): string[] {
const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
if (!isTestHomeGuardArmed()) return paths;
/*
* Under an armed test process the legacy default-home entry IS the developer's real
* `~/.opencodex/service-state.json`. It is there so an install made before
* OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME
* points at a sandbox still writes their live install state — observed while building
* the launchd repair coverage: one case replaced the real record's codexHome and
* opencodexHome with temp-directory paths. Drop it rather than deny the write, so the
* sandbox path keeps working and the real one is simply not in the list.
* The test resolver omits the legacy mirror; still reject an explicit or fallback
* OPENCODEX_HOME that names the developer's real home. A missing fixture pin must
* never allow a test to read or replace their live service installation.
*
* The predicate is the guard's own, not a local `resolve()` compare: the guard
* canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...`
Expand Down
8 changes: 8 additions & 0 deletions structure/ops/test-sandbox-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ 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.

Under the armed test-home guard, `src/service/state.ts` resolves service-state reads and
writes only within the selected `OPENCODEX_HOME`, including captured-home lookups. Bun
caches the startup home, so a legacy mirror resolved through `os.homedir()` would otherwise
be shared by parallel workers and serial fixture homes. Production retains the legacy
default-home mirror and migration lookup. `tests/service/service-state-isolation.test.ts`
plants a foreign startup-home record and verifies isolated reads, writes and fixture switches,
alongside the unchanged production mirror 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
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ opaque `installId` naming the owning installation rather than the user or the ma
`consentGeneration`. An absent claim means the CLI install that registered the service owns
the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS, Linux and Windows desktop claim from startup viability. Windows Desktop identity is read from `%APPDATA%\com.opencodex.desktop\install-id` and checked against live parentage; Windows Start at Login is unverified and Desktop never receives startup protection credit there. Independent [live Desktop supervision](desktop-shell.md#runtime-ownership-from-the-apps-side) can also grant startup protection without a claim when that app's login registration is verified; it never becomes ownership. On Linux, auto-launch 0.5.0 writes the login entry under HOME/.config/autostart, so a relocated XDG_CONFIG_HOME (outside that search path) is uncredited. Conditional entries and ambiguous Exec syntax are uncredited; the resolved desktop basename must match and its resolved ocx stays in the same directory. Both complete evidence reads must agree on ownership, install-id, entry contents, executable targets, PID and parent relationship; changes fail closed. AppImage registrations are uncredited because they name the outer image rather than the mounted executable. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. An enabled, identity-matching macOS LaunchAgent registration remains viable when `launchctl print` specifically proves the job is absent in the current session. `src/service/desktop-startup.ts` reports `registered-not-loaded` for next login; wrong plist paths/arguments/label, disabled override, unknown failures and supervisor races still refuse credit. `src/codex/autostart-health.ts` distinguishes this protected registration in restart safety. `src/service/linux-linger.ts` uses bounded `loginctl show-user` to report already-enabled linger, advise activation only when disabled, or explicitly report unavailable diagnosis. `src/cli/ensure-desktop-cold-start.ts` launches a claimed Desktop only on macOS. Its LaunchAgent executable path must be POSIX-absolute, including when the launch probe is exercised on another host; Windows drive paths do not satisfy that plist contract. Launches on Linux and Windows refuse without invoking a command.

Every write goes through `swapServiceInstallState`. With a custom home, the default-home
Every write goes through `swapServiceInstallState`. Guarded tests use only their selected home; see [test state isolation](ops/test-sandbox-cleanup.md). Production retains both paths. With a custom home, the default-home
record is the authority every writer can derive and the active-home record is a compatibility
mirror; with one path, that path is authoritative. `src/service/state-lock.ts` holds
token/PID/process-instance locks for every path in canonical order. A live holder is never
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"codex-history-pending-wal.test.ts": "codex-integration",
"compaction-history-framing.test.ts": "responses",
"service-log.test.ts": "service",
"service-state-isolation.test.ts": "service",
"owner-registry-isolation.test.ts": "config",
"service-health-sentinel-policy.test.ts": "service",
"service-health-sentinel-runner.test.ts": "service",
Expand Down
81 changes: 81 additions & 0 deletions tests/service/service-state-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { createTempHome } from "../helpers/temp-home";
import { repoPath } from "../helpers/repo-root";
import { spawnTestChild } from "../helpers/spawn-test-child";

const stateModule = pathToFileURL(repoPath("src/service/state.ts")).href;

describe("service state home isolation", () => {
for (const guarded of [true, false]) {
test(`${guarded ? "guarded tests isolate" : "production preserves"} the startup-home mirror`, async () => {
const home = createTempHome("ocx-service-state-isolation-");
try {
const startupHome = home.path("startup");
const startupState = join(startupHome, ".opencodex", "service-state.json");
mkdirSync(join(startupHome, ".opencodex"), { recursive: true });
const foreign = {
version: 2,
backend: "scheduler",
codexHome: home.path("foreign-codex"),
opencodexHome: home.path("foreign-opencodex"),
};
const foreignBytes = JSON.stringify(foreign);
writeFileSync(startupState, foreignBytes);
const configHome = home.path("case-a");
const nextConfigHome = home.path("case-b");
const child = await spawnTestChild([process.execPath, "-e", `
import { homedir } from "node:os";
import { readServiceInstallState, serviceStatePathsForOpenCodexHome,
writeServiceInstallState } from ${JSON.stringify(stateModule)};
// Match the preload: changing HOME cannot move Bun's cached startup home.
const startupHome = homedir();
process.env.HOME = process.env.USERPROFILE = ${JSON.stringify(home.path("worker"))};
const before = readServiceInstallState();
const pinned = serviceStatePathsForOpenCodexHome(${JSON.stringify(nextConfigHome)});
writeServiceInstallState("scheduler", null);
const written = readServiceInstallState();
process.env.OPENCODEX_HOME = ${JSON.stringify(nextConfigHome)};
const next = readServiceInstallState();
console.log(JSON.stringify({ startupHome, before, pinned, written, next }));
`], {
cwd: home.root,
env: {
...process.env,
HOME: startupHome,
USERPROFILE: startupHome,
// The wrapper protects the true developer home, not its own sandbox.
OCX_REAL_HOME: home.path("protected-real-home"),
OCX_TEST_HOME_GUARD: guarded ? "1" : "0",
OPENCODEX_HOME: configHome,
CODEX_HOME: home.codexHome,
},
deadlineMs: 10_000,
});
expect(child.timedOut).toBe(false);
expect(child.reaped).toBe(true);
expect(child.pipesClosed).toBe(true);
expect(child.exitCode, child.stderr).toBe(0);
const result = JSON.parse(child.stdout.trim());
expect(result.startupHome).toBe(startupHome);
expect(result.written.opencodexHome).toBe(configHome);
expect(result.written.codexHome).toBe(home.codexHome);
if (guarded) {
expect(result.before).toBeNull();
expect(result.pinned).toEqual([join(nextConfigHome, "service-state.json")]);
expect(result.next).toBeNull();
expect(readFileSync(startupState, "utf8")).toBe(foreignBytes);
} else {
expect(result.before).toEqual(foreign);
expect(result.pinned).toEqual([join(nextConfigHome, "service-state.json"), startupState]);
expect(result.next).toEqual(result.written);
expect(JSON.parse(readFileSync(startupState, "utf8"))).toEqual(result.written);
}
} finally {
home.remove();
}
}, 20_000);
}
});
Loading