diff --git a/tests/claude-integration/claude-config-root-late-admission.test.ts b/tests/claude-integration/claude-config-root-late-admission.test.ts index 1f2c3f86a48..2d0b603b05d 100644 --- a/tests/claude-integration/claude-config-root-late-admission.test.ts +++ b/tests/claude-integration/claude-config-root-late-admission.test.ts @@ -21,6 +21,7 @@ import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user import { writeServiceApiTokenFile } from "../../src/lib/service-secrets"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; const turn = () => new Promise(done => setImmediate(done)); @@ -31,10 +32,12 @@ function deferred() { } const keys = ["OPENCODEX_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_TIMEOUT_MS", "OPENCODEX_ACL_VERIFY_EXISTING"] as const; let root: string; +let lifecycleLock: ReturnType; let previous: (string | undefined)[]; let restores: Array<() => void>; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-config-writer-barrier-")); + lifecycleLock = isolateClientLifecycleLock(root); previous = keys.map(key => process.env[key]); process.env.OPENCODEX_HOME = root; process.env.CODEX_HOME = join(root, "codex"); @@ -53,6 +56,7 @@ beforeEach(() => { afterEach(async () => { await paths.flushConfigDirHardeningAndReaps(root); for (const restore of restores.reverse()) restore(); + lifecycleLock.restore(); setAsyncIcaclsRunnerForTests(null); setAsyncIcaclsBeltSchedulerForTests(null); setNowForTests(null); diff --git a/tests/claude-integration/claude-config-root-writer-barrier.test.ts b/tests/claude-integration/claude-config-root-writer-barrier.test.ts index 9a6a8ed2aa2..44e34ec2906 100644 --- a/tests/claude-integration/claude-config-root-writer-barrier.test.ts +++ b/tests/claude-integration/claude-config-root-writer-barrier.test.ts @@ -16,6 +16,7 @@ import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user import { writeServiceApiTokenFile } from "../../src/lib/service-secrets"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; const turn = () => new Promise(done => setImmediate(done)); @@ -26,10 +27,12 @@ function deferred() { } const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_TIMEOUT_MS", "OPENCODEX_ACL_VERIFY_EXISTING"] as const; let root: string; +let lifecycleLock: ReturnType; let previous: (string | undefined)[]; let restores: Array<() => void>; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-config-writer-barrier-")); + lifecycleLock = isolateClientLifecycleLock(root); previous = keys.map(key => process.env[key]); process.env.OPENCODEX_HOME = root; process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); @@ -45,6 +48,7 @@ beforeEach(() => { afterEach(async () => { await paths.flushConfigDirHardeningAndReaps(root); for (const restore of restores.reverse()) restore(); + lifecycleLock.restore(); setAsyncIcaclsRunnerForTests(null); setAsyncIcaclsBeltSchedulerForTests(null); setNowForTests(null); diff --git a/tests/claude-integration/claude-desktop-first-party-guards.test.ts b/tests/claude-integration/claude-desktop-first-party-guards.test.ts index 4f89d8baf41..4c26984ee61 100644 --- a/tests/claude-integration/claude-desktop-first-party-guards.test.ts +++ b/tests/claude-integration/claude-desktop-first-party-guards.test.ts @@ -14,12 +14,14 @@ import { syncEnabledClientIntegrations } from "../../src/server/management/confi import type { CatalogModel } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; // Gateway is the default Desktop mode. These guards keep that default from ever writing a // gateway profile behind a Desktop that runs first-party, and keep foreign proxy env from being // read as a first-party install. let root = ""; +let lifecycleLock: ReturnType; let library = ""; let claudeDir = ""; const previous: Record = {}; @@ -78,6 +80,7 @@ function gatedDiscovery(models: CatalogModel[]) { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-guards-")); + lifecycleLock = isolateClientLifecycleLock(root); library = join(root, "desktop-library"); claudeDir = join(root, "claude"); for (const key of ENV_KEYS) previous[key] = process.env[key]; @@ -88,6 +91,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); resetSiblingStartForTests(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index fd084484a7f..3ab33edae87 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -1,7 +1,9 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, relative, sep } from "node:path"; +import * as userIdentity from "../../src/codex/user-identity"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; import { applyDesktopFirstParty, inspectDesktopFirstParty, @@ -25,6 +27,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; let root = ""; +let lifecycleLock: ReturnType; let library = ""; let claudeDir = ""; const previous: Record = {}; @@ -61,6 +64,7 @@ function expectStatus(result: { status: number; body: unknown }, status: number) beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); + lifecycleLock = isolateClientLifecycleLock(root); library = join(root, "desktop-library"); claudeDir = join(root, "claude"); for (const key of ENV_KEYS) previous[key] = process.env[key]; @@ -72,6 +76,7 @@ beforeEach(() => { afterEach(async () => { await flushConfigDirHardeningAndReaps(root); + lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; @@ -237,6 +242,30 @@ test("POST /api/claude-desktop/apply defaults to gateway; first-party is explici expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } }, { ownedFirstPartySettings: true })).toBe("first-party"); }); +test("management gateway and first-party apply acquire only fixture lifecycle locks", async () => { + // If fixture injection regresses, refuse before opening the real user's lock. + const productionRoot = spyOn(userIdentity, "resolveEffectiveUserRuntimeRoot").mockImplementation(() => { + throw new Error("management fixture reached the production lifecycle namespace"); + }); + try { + for (const mode of ["gateway", "first-party"] as const) { + lifecycleLock.acquisitions.length = 0; + const result = await dispatch("/api/claude-desktop/apply", { + method: "POST", body: JSON.stringify({ mode }), + }, JSON.parse(readFileSync(join(root, "config.json"), "utf8")), { fetchAllModels: async () => [] }); + expect(lifecycleLock.acquisitions.length).toBeGreaterThan(0); + for (const { path } of lifecycleLock.acquisitions) { + const within = relative(root, path); + expect(within).not.toBe(""); + expect(isAbsolute(within)).toBe(false); + expect(within === ".." || within.startsWith(`..${sep}`)).toBe(false); + } + expectStatus(result, 200); + } + expect(productionRoot).not.toHaveBeenCalled(); + } finally { productionRoot.mockRestore(); } +}); + test("native toggle: enable applies gateway by default and never writes first-party env", async () => { const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); expectStatus(enabled, 200); diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts index 3db427d6b29..87d0698bfb7 100644 --- a/tests/claude-integration/claude-desktop-picker-routes.test.ts +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -15,11 +15,13 @@ import { getClaudePickerRuntime, startClaudeIntercept, type ClaudeInterceptHandl import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; // End to end over the management routes: a real picker runtime and controller behind the // dedicated egress proxy, with the keychain, the claude.ai listener and discovery faked. let root = ""; +let lifecycleLock: ReturnType; let handle: ClaudeInterceptHandle | null = null; const previous: Record = {}; const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; @@ -124,6 +126,7 @@ test("picker egress uses HTTPS Desktop entrypoint after a UA-less CONNECT", asyn beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-routes-")); + lifecycleLock = isolateClientLifecycleLock(root); for (const key of ENV_KEYS) previous[key] = process.env[key]; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop-library"); @@ -135,6 +138,7 @@ beforeEach(() => { afterEach(async () => { await handle?.stop(); handle = null; + lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; diff --git a/tests/claude-integration/claude-first-party-agent-sync.test.ts b/tests/claude-integration/claude-first-party-agent-sync.test.ts index 83e46840c34..fd2f2428b2c 100644 --- a/tests/claude-integration/claude-first-party-agent-sync.test.ts +++ b/tests/claude-integration/claude-first-party-agent-sync.test.ts @@ -8,8 +8,10 @@ import { reconcileClaudeFirstPartySettings } from "../../src/claude/first-party- import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; let claudeDir = ""; let config: OcxConfig; const previous: Record = {}; @@ -60,6 +62,7 @@ async function dispatch(target: "cli" | "desktop", enabled = true, deps: Managem beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-first-party-agents-")); + lifecycleLock = isolateClientLifecycleLock(root); claudeDir = join(root, "claude"); for (const key of ENV_KEYS) previous[key] = process.env[key]; process.env.OPENCODEX_HOME = root; @@ -77,6 +80,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; diff --git a/tests/claude-integration/claude-intercept-acl-barrier.test.ts b/tests/claude-integration/claude-intercept-acl-barrier.test.ts index 8d922cbef66..1cf2059492e 100644 --- a/tests/claude-integration/claude-intercept-acl-barrier.test.ts +++ b/tests/claude-integration/claude-intercept-acl-barrier.test.ts @@ -26,6 +26,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { previewClaudeModelDefaults } from "../../src/claude/model-default-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const success: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; const sid = "S-1-5-21-1-2-3-1001"; @@ -38,11 +39,13 @@ function deferred() { } const keys = ["OPENCODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "OPENCODEX_ACL_VERIFY_EXISTING", "OPENCODEX_ACL_TIMEOUT_MS"] as const; let root: string; +let lifecycleLock: ReturnType; let previous: (string | undefined)[]; let entrySpy: ReturnType | undefined; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-intercept-acl-barrier-")); + lifecycleLock = isolateClientLifecycleLock(root); previous = keys.map(key => process.env[key]); process.env.OPENCODEX_HOME = root; process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); @@ -77,6 +80,7 @@ afterEach(async () => { await flushConfigDirHardeningAndReaps(root); entrySpy?.mockRestore(); entrySpy = undefined; + lifecycleLock.restore(); setAsyncIcaclsRunnerForTests(null); setAsyncIcaclsBeltSchedulerForTests(null); setNowForTests(null); diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 1663a564e8a..50152c6d0a3 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -32,6 +32,7 @@ import { estimateTokens } from "../../src/lib/token-estimate"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -49,6 +50,7 @@ import { } from "../../src/codex/native-profile-startup"; let testDir = ""; +let lifecycleLock: ReturnType; let previousHome: string | undefined; let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; @@ -58,6 +60,7 @@ beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-claude-endpoint-"); testDir = mkdtempSync(join(tmpdir(), "ocx-claude-endpoint-")); + lifecycleLock = isolateClientLifecycleLock(testDir); process.env.OPENCODEX_HOME = testDir; previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); @@ -66,6 +69,7 @@ beforeEach(() => { afterEach(async () => { if (testDir) await flushConfigDirHardeningAndReaps(testDir); + lifecycleLock.restore(); if (isolatedCodexHome) await flushConfigDirHardeningAndReaps(isolatedCodexHome.path); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; diff --git a/tests/helpers/client-lifecycle-lock.ts b/tests/helpers/client-lifecycle-lock.ts new file mode 100644 index 00000000000..b467bc9cade --- /dev/null +++ b/tests/helpers/client-lifecycle-lock.ts @@ -0,0 +1,26 @@ +import { spyOn } from "bun:test"; +import { join, resolve } from "node:path"; +import * as clientLifecycle from "../../src/client/lifecycle-lock"; + +const realSync = clientLifecycle.withClientLifecycleSync; +const realAsync = clientLifecycle.withClientLifecycle; + +/** Keep real SQLite exclusion and leases, without sharing the OS user's Desktop lock. */ +export function isolateClientLifecycleLock(root: string) { + const acquisitions: Array<{ kind: "sync" | "async"; path: string }> = []; + function isolatedDeps(kind: "sync" | "async", deps?: clientLifecycle.ClientLifecycleLockDeps) { + const lockPath = deps?.lockPath ?? join(root, "locks", "lifecycle.sqlite"); + acquisitions.push({ kind, path: resolve(lockPath) }); + return { ...deps, lockPath }; + } + const sync = spyOn(clientLifecycle, "withClientLifecycleSync").mockImplementation( + (work, deps) => realSync(work, isolatedDeps("sync", deps)), + ); + const async = spyOn(clientLifecycle, "withClientLifecycle").mockImplementation( + (work, deps) => realAsync(work, isolatedDeps("async", deps)), + ); + return { + acquisitions, + restore() { async.mockRestore(); sync.mockRestore(); }, + }; +}