diff --git a/tests/claude-integration/claude-desktop-picker.test.ts b/tests/claude-integration/claude-desktop-picker.test.ts index 0463ea3c52e..785fff68c17 100644 --- a/tests/claude-integration/claude-desktop-picker.test.ts +++ b/tests/claude-integration/claude-desktop-picker.test.ts @@ -15,8 +15,10 @@ import { createCertificateAuthority } from "../../src/claude/intercept/local-ca" import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "../../src/claude/intercept/picker-ca"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; function config(extra: Partial = {}): OcxConfig { return { @@ -136,8 +138,11 @@ function controllerFor(options: { }); } -beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-controller-")); }); -afterEach(() => { rmSync(root, { recursive: true, force: true }); }); +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-picker-controller-")); + lifecycleLock = isolateClientLifecycleLock(root); +}); +afterEach(() => { lifecycleLock.restore(); rmSync(root, { recursive: true, force: true }); }); test("enable orders trust, fresh recheck, profile, and rearm", async () => { const events: string[] = []; diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 5da59871e28..86f96646ed9 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -15,12 +15,14 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/iso import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { desktopFirstPartyTarget } from "../../src/claude/desktop-first-party"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; // Full-suite Windows load: startServer + multi-PUT management flows often exceed bun's // default 5s per-test budget (same flake class as 810fa115 / kiro-oauth). setDefaultTimeout(30_000); let testDir = ""; +let lifecycleLock: ReturnType; let previousHome: string | undefined; let previousClaudeConfigDir: string | undefined; let previousDesktopConfigDir: string | undefined; @@ -45,6 +47,7 @@ beforeEach(() => { previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; isolatedCodexHome = installIsolatedCodexHome("ocx-claude-mgmt-"); testDir = mkdtempSync(join(tmpdir(), "ocx-claude-mgmt-")); + lifecycleLock = isolateClientLifecycleLock(testDir); process.env.OPENCODEX_HOME = testDir; // These API tests intentionally toggle agent injection off. Never let that // prune the developer's real ~/.claude/agents directory. @@ -60,6 +63,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; diff --git a/tests/claude-integration/claude-picker-relay-intent.test.ts b/tests/claude-integration/claude-picker-relay-intent.test.ts index 66366aaff25..dd2c7444f62 100644 --- a/tests/claude-integration/claude-picker-relay-intent.test.ts +++ b/tests/claude-integration/claude-picker-relay-intent.test.ts @@ -11,10 +11,12 @@ import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/i import type { OcxConfig } from "../../src/types"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const envKeys = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; let previous: Array; let root: string; +let lifecycleLock: ReturnType; let config: OcxConfig; let handle: Awaited>; let upstream: Server | undefined; @@ -23,6 +25,7 @@ const binds: Array<{ requested: number; port: number; options: ConnectProxyOptio beforeEach(() => { previous = envKeys.map(key => process.env[key]); root = mkdtempSync(join(tmpdir(), "ocx-picker-relay-intent-")); + lifecycleLock = isolateClientLifecycleLock(root); process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); @@ -38,6 +41,7 @@ afterEach(async () => { await handle?.stop(); if (upstream) await new Promise((resolve, reject) => upstream!.close(error => error ? reject(error) : resolve())); } finally { + lifecycleLock.restore(); handle = null; upstream = undefined; envKeys.forEach((key, index) => { diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index 095938bcad6..695fdb15efe 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -20,11 +20,13 @@ import { type PickerRuntime, } from "../../src/claude/intercept/picker-runtime"; import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; -import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; +import { getClaudePickerController, getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; let root = ""; +let lifecycleLock: ReturnType; const previous: Record = {}; const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; const running: PickerRuntime[] = []; @@ -87,6 +89,7 @@ const INTERCEPT = { kind: "intercept", port: LISTENER_PORT }; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-runtime-")); + 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"); @@ -94,13 +97,18 @@ beforeEach(() => { }); afterEach(async () => { - for (const created of running.splice(0)) await created.stop(); - for (const close of closeFixtures.splice(0)) await close(); - for (const key of ENV_KEYS) { - if (previous[key] === undefined) delete process.env[key]; - else process.env[key] = previous[key]; + try { + for (const created of running.splice(0)) await created.stop(); + for (const close of closeFixtures.splice(0)) await close(); + } finally { + // A rejected stop or close must not leave the lifecycle spies, environment or root behind. + lifecycleLock.restore(); + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); } - removeTreeWithRetry(root); }); describe("pickerDesired", () => { @@ -300,6 +308,13 @@ async function heldEgress(dialUpstream?: Parameters[1] return { port: proxy.port, startProxy, close: proxy.close }; } +// A restart with an applied profile starts a detached controller.enable; join it before stopping, +// so it cannot run after afterEach restores the lifecycle spy and reach the account-wide lock. +async function stopAfterPickerSettles(handle: { stop(): Promise } | null | undefined): Promise { + try { await getClaudePickerController()?.transition(async () => {}); } + finally { await handle?.stop(); } +} + const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Claude/2.7032.0"; function connectStatusLine(port: number, host: string, userAgent?: string): Promise { @@ -436,7 +451,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("add-trusted-cert"); expect(getClaudePickerRuntime()?.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -469,7 +484,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("remove-trusted-cert"); expect(names).not.toContain("delete-certificate"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -520,7 +535,7 @@ describe("startClaudeIntercept wiring", () => { expect(pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha1).not.toBe(foreignSha1); expect(getClaudePickerRuntime()).toBe(fake); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -556,7 +571,7 @@ describe("startClaudeIntercept wiring", () => { expect(names).not.toContain("remove-trusted-cert"); expect(names).not.toContain("delete-certificate"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); }); @@ -628,7 +643,7 @@ describe("startClaudeIntercept wiring", () => { expect(await canBind(handle!.proxyPort)).toBe(false); expect(await connectStatusLine(handle!.proxyPort, "api.anthropic.com")).toContain("200"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); await new Promise(resolve => upstream.close(() => resolve())); } expect(getClaudePickerRuntime()).toBeNull(); @@ -669,7 +684,7 @@ describe("startClaudeIntercept wiring", () => { expect(readFileSync(join(pickerStateDir(root), "profile-state.json"), "utf8")).toBe(stateBefore); expect(existsSync(pickerCaPendingUntrustPath(root))).toBe(true); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); await new Promise(resolve => squatter.close(() => resolve())); } }); @@ -701,7 +716,7 @@ describe("startClaudeIntercept wiring", () => { expect(calls).toEqual([]); expect(readFileSync(pickerCaPendingUntrustPath(root), "utf8")).toBe("{corrupt"); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } }); @@ -769,7 +784,7 @@ describe("startClaudeIntercept wiring", () => { const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { entries: { name: string }[] }; expect(metadata.entries.some(entry => entry.name === "opencodex-standard")).toBe(false); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } }); @@ -827,7 +842,7 @@ describe("startClaudeIntercept wiring", () => { expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" })) .toMatchObject({ kind: "applied", entryId: original.entryId, proxyUrl: original.proxyUrl }); } finally { - await first?.stop(); + await stopAfterPickerSettles(first); } // The next restart adopts the held relay; the surviving selection restores in place. retry = true; @@ -845,7 +860,7 @@ describe("startClaudeIntercept wiring", () => { expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" })) .toMatchObject({ kind: "applied", entryId: original.entryId, proxyUrl: restoredUrl }); } finally { - await second?.stop(); + await stopAfterPickerSettles(second); } } finally { await egress.close(); } }); @@ -886,7 +901,7 @@ describe("startClaudeIntercept wiring", () => { expect(await connectStatusLine(pickerPort, "spoofed-ua.invalid", `${BROWSER_UA} spoofed`)).toContain("502"); expect(asked).toEqual(["picker-probe.invalid", "spoofed-ua.invalid"]); } finally { - await handle?.stop(); + await stopAfterPickerSettles(handle); } expect(getClaudePickerRuntime()).toBeNull(); expect(await canBind(pickerPort)).toBe(true); diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index ce90a5a581b..1b48c490c50 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; @@ -17,8 +17,11 @@ import { flushConfigDirHardeningAndReaps, hardenConfigDir } from "../../src/conf import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; +let lifecycleLock: ReturnType; +let lockRoot = ""; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -29,6 +32,9 @@ const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; // even when an earlier one fails, the environment is always restored, and failures are rethrown. async function cleanupPickerStartup(remove: (dir: string) => void = removeTreeWithRetry): Promise { const failures: unknown[] = []; + // A restart with an applied profile starts a detached controller.enable; join it before the + // handles stop and the lifecycle spy is restored, so it cannot reach the account-wide lock. + try { await getClaudePickerController()?.transition(async () => {}); } catch (error) { failures.push(error); } for (const handle of handles.splice(0)) { try { await handle?.stop(); } catch (error) { failures.push(error); } } @@ -43,7 +49,15 @@ async function cleanupPickerStartup(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-startup cleanup failed"); } -afterEach(() => cleanupPickerStartup()); +// Cases own multiple roots; keep their cleanup oracle intact and give the lock its own temp root. +beforeEach(() => { + lockRoot = mkdtempSync(join(tmpdir(), "ocx-picker-startup-lock-")); + lifecycleLock = isolateClientLifecycleLock(lockRoot); +}); +afterEach(async () => { + try { await cleanupPickerStartup(); } + finally { lifecycleLock.restore(); removeTreeWithRetry(lockRoot); } +}); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); function setup() { const root = mkdtempSync(join(tmpdir(), "ocx-picker-startup-")); roots.push(root); diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index e67da03ab17..ba5b2e6d58e 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { createServer as createHttpsServer } from "node:https"; import { connect } from "node:net"; @@ -26,8 +26,11 @@ import { import type { SecurityResult, SecurityRunner } from "../../src/claude/intercept/picker-trust"; import { filePickerCaStore, memoryPickerCaStore } from "../helpers/picker-ca-store"; import type { OcxConfig } from "../../src/types"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; const roots: string[] = []; +let lifecycleLock: ReturnType; +let lockRoot = ""; const handles: Array>> = []; const priorHome = process.env.OPENCODEX_HOME; const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -37,6 +40,9 @@ const priorClaude = process.env.CLAUDE_CONFIG_DIR; // Drain hardening and reaps before retrying removal; retain failures and restore env. async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWithRetry): Promise { const failures: unknown[] = []; + // A restart with an applied profile starts a detached controller.enable; join it before the + // handles stop and the lifecycle spy is restored, so it cannot reach the account-wide lock. + try { await getClaudePickerController()?.transition(async () => {}); } catch (error) { failures.push(error); } for (const handle of handles.splice(0)) { try { await handle?.stop(); } catch (error) { failures.push(error); } } @@ -52,7 +58,15 @@ async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWi if (failures.length === 1) throw failures[0]; if (failures.length > 1) throw new AggregateError(failures, "picker-windows cleanup failed"); } -afterEach(() => cleanupPickerWindows()); +// Cases own multiple roots; keep their cleanup oracle intact and give the lock its own temp root. +beforeEach(() => { + lockRoot = mkdtempSync(join(tmpdir(), "ocx-picker-windows-lock-")); + lifecycleLock = isolateClientLifecycleLock(lockRoot); +}); +afterEach(async () => { + try { await cleanupPickerWindows(); } + finally { lifecycleLock.restore(); removeTreeWithRetry(lockRoot); } +}); const routes = async () => ({ nativeSlugs: [], routedModels: [] }); diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index 7e13259cbaa..5c2dd99da6c 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -10,8 +10,10 @@ import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src import type { ManagementApiDeps } from "../../src/server/management/context"; 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 library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; @@ -106,6 +108,7 @@ function oversizedTrackedBody(): { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-desktop-toggle-")); + lifecycleLock = isolateClientLifecycleLock(root); library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; @@ -117,6 +120,7 @@ beforeEach(() => { }); afterEach(() => { + lifecycleLock.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR;