diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index e14b8ab5a1e7..f9a3255eddb7 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({ }, bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions), reconcileCodexComputerUseStartArtifacts: vi.fn( - async (_params?: { startOptions: { command: string } }) => undefined, + async (_params?: { + startOptions: { command: string }; + desktopGeneration?: { epoch: number; fingerprint: string }; + }) => undefined, ), applyCodexAppServerAuthProfile: vi.fn( async (_params?: { @@ -2308,6 +2311,60 @@ describe("shared Codex app-server client", () => { expect(releaseLeasedSharedCodexAppServerClient(clientY)).toBe(true); }); + it("tracks a package-first client whose Computer Use artifacts come from the desktop", async () => { + const generationX = { epoch: 1, fingerprint: "desktop-x" }; + const generationY = { epoch: 2, fingerprint: "desktop-y" }; + mocks.desktopGeneration = generationX; + mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(async (startOptions) => ({ + ...startOptions, + command: "/cache/openclaw/codex", + commandSource: "resolved-managed" as const, + managedFallbackCommandPaths: ["/Applications/Codex.app/Contents/Resources/codex"], + })); + const packageX = createClientHarness(); + const packageY = createClientHarness(); + const startSpy = vi + .spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(packageX.client) + .mockReturnValueOnce(packageY.client); + const options = { + config: {}, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + agentDir: "/tmp/openclaw-agent", + startOptions: { + transport: "stdio" as const, + homeScope: "agent" as const, + command: "codex", + commandSource: "managed" as const, + managedCommandOrder: "package-first" as const, + args: ["app-server"], + headers: {}, + }, + }; + + const firstAcquire = getLeasedSharedCodexAppServerClient(options); + await sendInitializeResult(packageX, "openclaw/0.148.0 (macOS; test)"); + const clientX = await firstAcquire; + + mocks.desktopGeneration = generationY; + retireSharedCodexAppServerClientsBeforeDesktopGeneration(generationY); + const replacementAcquire = getLeasedSharedCodexAppServerClient(options); + await sendInitializeResult(packageY, "openclaw/0.148.0 (macOS; test)"); + const clientY = await replacementAcquire; + + expect(clientY).not.toBe(clientX); + expect(startSpy).toHaveBeenCalledTimes(2); + expect( + mocks.reconcileCodexComputerUseStartArtifacts.mock.calls.map( + ([params]) => params?.desktopGeneration, + ), + ).toEqual([generationX, generationY]); + expect(packageX.process.stdin.destroyed).toBe(false); + expect(releaseLeasedSharedCodexAppServerClient(clientX)).toBe(true); + expect(packageX.process.stdin.destroyed).toBe(true); + expect(releaseLeasedSharedCodexAppServerClient(clientY)).toBe(true); + }); + it("binds a package-first acquisition when its actual fallback is a desktop app", async () => { const generationX = { epoch: 1, fingerprint: "desktop-x" }; const generationY = { epoch: 2, fingerprint: "desktop-y" }; diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 471bc0e5ab38..dd867193ca65 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -29,6 +29,7 @@ import { ensureCodexAppServerClientRuntime } from "./client-runtime.js"; import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js"; import { codexAppServerStartOptionsKey, + resolveCodexComputerUseConfig, resolveCodexAppServerRuntimeOptions, resolveCodexAppServerStartOptionsForAgent, resolveCodexAppServerUserHomeDir, @@ -335,7 +336,10 @@ async function resolveCodexAppServerClientStartContext( const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {}); const requestedStartOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start; - const desktopGeneration = shouldTrackDesktopGeneration(requestedStartOptions) + const desktopGeneration = shouldTrackDesktopGeneration( + requestedStartOptions, + options?.pluginConfig, + ) ? await waitForCodexDesktopGeneration() : undefined; const preparedAuth = options?.preparedAuth; @@ -447,11 +451,18 @@ async function resolveCodexAppServerClientStartContext( }; } -function shouldTrackDesktopGeneration(startOptions: CodexAppServerStartOptions): boolean { +function shouldTrackDesktopGeneration( + startOptions: CodexAppServerStartOptions, + pluginConfig: unknown, +): boolean { + if (startOptions.transport !== "stdio" || startOptions.commandSource !== "managed") { + return false; + } + // An explicitly package-first process can still publish and load Computer Use + // artifacts from a desktop app, so it belongs to that desktop generation too. return ( - startOptions.transport === "stdio" && - startOptions.commandSource === "managed" && - (startOptions.managedCommandOrder ?? "package-first") === "desktop-first" + (startOptions.managedCommandOrder ?? "package-first") === "desktop-first" || + resolveCodexComputerUseConfig({ pluginConfig }).enabled ); }