From 8140d93e3c40b51bf3326b45c52c193782877e86 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:36:46 -0500 Subject: [PATCH] fix(codex): bind desktop artifact reconciliation to generation --- .../codex/src/app-server/auth-bridge.test.ts | 332 +++++++++++++++++- .../codex/src/app-server/auth-bridge.ts | 243 +++++++++++-- .../src/app-server/computer-use-cache.test.ts | 84 +++++ .../src/app-server/computer-use-cache.ts | 15 +- .../computer-use-marketplace.test.ts | 188 ++++++++++ .../app-server/computer-use-marketplace.ts | 59 ++-- .../app-server/computer-use-service.test.ts | 38 ++ .../src/app-server/computer-use-service.ts | 23 ++ .../codex/src/app-server/computer-use.test.ts | 119 ++++++- .../codex/src/app-server/computer-use.ts | 39 +- .../codex/src/app-server/models.test.ts | 2 + .../src/app-server/shared-client.test.ts | 160 ++++++++- .../codex/src/app-server/shared-client.ts | 82 +++-- 13 files changed, 1248 insertions(+), 136 deletions(-) diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index cd6309df0499..48646dbfcad0 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -7,6 +7,7 @@ import { loadAuthProfileStoreForSecretsRuntime, replaceRuntimeAuthProfileStoreSnapshots, } from "openclaw/plugin-sdk/agent-runtime"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -14,6 +15,7 @@ import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions, refreshCodexAppServerAuthTokens, + reconcileCodexComputerUseStartArtifacts, resolveCodexAppServerAuthAccountCacheKey, resolveCodexAppServerAuthProfileId, resolveCodexAppServerAuthProfileStore, @@ -24,18 +26,49 @@ import { resolveCodexAppServerPreparedApiKeyCacheKey, } from "./auth-bridge.js"; import type { CodexAppServerStartOptions } from "./config.js"; +import { resolveMacOSDesktopCodexAppPathCandidates } from "./desktop-app-paths.js"; import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js"; const oauthMocks = vi.hoisted(() => ({ refreshOpenAICodexToken: vi.fn(), })); +type MockDesktopCandidate = ReturnType[number]; +type MockCacheResult = { + status: "independent" | "shared"; + changed: boolean; + message: string; + removedStaleVersions: string[]; + warnings: string[]; +}; + const computerUseServiceMocks = vi.hoisted(() => ({ - ensureCodexManagedBundledMarketplace: vi.fn(async () => undefined), - ensureCodexComputerUseServiceApp: vi.fn(async () => ({ - status: "already_current" as const, + ensureCodexComputerUseSharedPluginCache: vi.fn< + (_params: { forceRefresh?: boolean }) => Promise + >(async () => ({ + status: "independent", changed: false, + message: "independent", + removedStaleVersions: [], + warnings: [], })), + ensureCodexManagedBundledMarketplace: vi.fn<(_params?: unknown) => Promise>( + async () => undefined, + ), + ensureCodexComputerUseServiceApp: vi.fn< + (_params?: unknown) => Promise<{ + status: "already_current" | "source_missing"; + changed: boolean; + }> + >(async () => ({ status: "already_current", changed: false })), + resolveCodexManagedBundledMarketplaceSource: vi.fn< + (params: { + candidates?: readonly MockDesktopCandidate[]; + }) => Promise + >(async (params) => params.candidates?.[0]), + resolveCodexComputerUseServiceAppSourcePath: vi.fn< + (params: { sourceAppCandidates?: readonly string[] }) => Promise + >(async (params) => params.sourceAppCandidates?.[0]), })); const providerRuntimeMocks = vi.hoisted(() => ({ @@ -127,11 +160,20 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { vi.mock("./computer-use-service.js", () => ({ ensureCodexComputerUseServiceApp: computerUseServiceMocks.ensureCodexComputerUseServiceApp, + resolveCodexComputerUseServiceAppSourcePath: + computerUseServiceMocks.resolveCodexComputerUseServiceAppSourcePath, })); vi.mock("./computer-use-marketplace.js", () => ({ ensureCodexManagedBundledMarketplace: computerUseServiceMocks.ensureCodexManagedBundledMarketplace, + resolveCodexManagedBundledMarketplaceSource: + computerUseServiceMocks.resolveCodexManagedBundledMarketplaceSource, +})); + +vi.mock("./computer-use-cache.js", () => ({ + ensureCodexComputerUseSharedPluginCache: + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache, })); afterEach(() => { @@ -142,6 +184,22 @@ afterEach(() => { providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear(); computerUseServiceMocks.ensureCodexComputerUseServiceApp.mockClear(); computerUseServiceMocks.ensureCodexManagedBundledMarketplace.mockClear(); + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache.mockReset(); + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache.mockResolvedValue({ + status: "independent", + changed: false, + message: "independent", + removedStaleVersions: [], + warnings: [], + }); + computerUseServiceMocks.resolveCodexManagedBundledMarketplaceSource.mockReset(); + computerUseServiceMocks.resolveCodexManagedBundledMarketplaceSource.mockImplementation( + async (params) => params.candidates?.[0], + ); + computerUseServiceMocks.resolveCodexComputerUseServiceAppSourcePath.mockReset(); + computerUseServiceMocks.resolveCodexComputerUseServiceAppSourcePath.mockImplementation( + async (params: { sourceAppCandidates?: readonly string[] }) => params.sourceAppCandidates?.[0], + ); }); function createStartOptions( @@ -344,31 +402,36 @@ describe("bridgeCodexAppServerStartOptions", () => { it("provisions the native Computer Use client before auto-install startup", async () => { await withTempDir("openclaw-codex-computer-use-service-", async (agentDir) => { + computerUseServiceMocks.ensureCodexManagedBundledMarketplace.mockResolvedValueOnce( + "/managed/openai-bundled", + ); const startOptions = createStartOptions(); const codexHome = resolveCodexAppServerHomeDir(agentDir); - await bridgeCodexAppServerStartOptions({ + await reconcileCodexComputerUseStartArtifacts({ startOptions, agentDir, pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, }); - expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith({ - codexHome, - ownershipRoot: agentDir, - appServerCommand: startOptions.command, - }); - expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).toHaveBeenCalledWith({ - codexHome, - ownershipRoot: agentDir, - appServerCommand: startOptions.command, - }); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome, + ownershipRoot: agentDir, + }), + ); + expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome, + ownershipRoot: agentDir, + }), + ); }); }); it("does not provision the native client without auto-install authorization", async () => { await withTempDir("openclaw-codex-computer-use-service-", async (agentDir) => { - await bridgeCodexAppServerStartOptions({ + await reconcileCodexComputerUseStartArtifacts({ startOptions: createStartOptions(), agentDir, pluginConfig: { computerUse: { enabled: true, autoInstall: false } }, @@ -379,12 +442,136 @@ describe("bridgeCodexAppServerStartOptions", () => { }); }); + it("rejects a desktop candidate whose exact bundled marketplace is unavailable", async () => { + await withTempDir("openclaw-codex-computer-use-source-missing-", async (agentDir) => { + computerUseServiceMocks.resolveCodexManagedBundledMarketplaceSource.mockResolvedValueOnce( + undefined, + ); + + await expect( + reconcileCodexComputerUseStartArtifacts({ + startOptions: createStartOptions({ + command: "/Applications/ChatGPT.app/Contents/Resources/codex", + }), + agentDir, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + }), + ).rejects.toMatchObject({ + code: "CODEX_COMPUTER_USE_CANDIDATE_ARTIFACTS_UNAVAILABLE", + }); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).not.toHaveBeenCalled(); + expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).not.toHaveBeenCalled(); + }); + }); + + it("rejects a desktop candidate whose exact signed service is unavailable", async () => { + await withTempDir("openclaw-codex-computer-use-service-missing-", async (agentDir) => { + computerUseServiceMocks.resolveCodexComputerUseServiceAppSourcePath.mockResolvedValueOnce( + undefined, + ); + + await expect( + reconcileCodexComputerUseStartArtifacts({ + startOptions: createStartOptions({ + command: "/Applications/ChatGPT.app/Contents/Resources/codex", + }), + agentDir, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + }), + ).rejects.toMatchObject({ + code: "CODEX_COMPUTER_USE_CANDIDATE_ARTIFACTS_UNAVAILABLE", + }); + expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).not.toHaveBeenCalled(); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).not.toHaveBeenCalled(); + }); + }); + + it.each([ + { marketplaceSource: "file:///tmp/custom-marketplace" }, + { marketplacePath: "/tmp/custom-marketplace/marketplace.json" }, + { marketplaceName: "custom-marketplace" }, + ])("keeps an exact desktop candidate with configured marketplace selection", async (selector) => { + await withTempDir("openclaw-codex-computer-use-custom-source-", async (agentDir) => { + await expect( + reconcileCodexComputerUseStartArtifacts({ + startOptions: createStartOptions({ + command: "/Applications/ChatGPT.app/Contents/Resources/codex", + }), + agentDir, + pluginConfig: { + computerUse: { enabled: true, autoInstall: true, ...selector }, + }, + }), + ).resolves.toBeUndefined(); + expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).not.toHaveBeenCalled(); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledOnce(); + }); + }); + + it.each(["marketplace", "service"] as const)( + "keeps package fallback artifacts on one complete desktop owner when ChatGPT lacks %s", + async (missingArtifact) => { + await withTempDir("openclaw-codex-computer-use-package-owner-", async (agentDir) => { + const candidates = resolveMacOSDesktopCodexAppPathCandidates("darwin"); + const codexCandidate = candidates.find((candidate) => candidate.appName === "Codex.app"); + if (!codexCandidate) { + throw new Error("expected Codex.app candidate"); + } + computerUseServiceMocks.resolveCodexManagedBundledMarketplaceSource.mockImplementation( + async (params) => { + const candidate = params.candidates?.[0]; + return missingArtifact === "marketplace" && candidate?.appName === "ChatGPT.app" + ? undefined + : candidate; + }, + ); + computerUseServiceMocks.resolveCodexComputerUseServiceAppSourcePath.mockImplementation( + async (params: { sourceAppCandidates?: readonly string[] }) => { + const source = params.sourceAppCandidates?.[0]; + return missingArtifact === "service" && source?.includes("ChatGPT.app") + ? undefined + : source; + }, + ); + computerUseServiceMocks.ensureCodexManagedBundledMarketplace.mockResolvedValueOnce( + "/managed/openai-bundled", + ); + + await reconcileCodexComputerUseStartArtifacts({ + startOptions: createStartOptions({ command: "/cache/openclaw/codex" }), + agentDir, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + }); + + expect(computerUseServiceMocks.ensureCodexManagedBundledMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + candidates: [codexCandidate], + appServerCommand: codexCandidate.appServerCommandPath, + }), + ); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAppCandidates: codexCandidate.computerUseServiceAppPaths, + appServerCommand: codexCandidate.appServerCommandPath, + }), + ); + expect( + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache, + ).toHaveBeenCalledWith( + expect.objectContaining({ + bundledMarketplacePath: codexCandidate.bundledMarketplacePath, + }), + ); + }); + }, + ); + it("does not replace the native service app for user-scoped homes", async () => { await withTempDir("openclaw-codex-computer-use-user-home-", async (root) => { const codexHome = path.join(root, "user-codex-home"); vi.stubEnv("CODEX_HOME", codexHome); - await bridgeCodexAppServerStartOptions({ + await reconcileCodexComputerUseStartArtifacts({ startOptions: createStartOptions({ homeScope: "user" }), agentDir: path.join(root, "agent"), pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, @@ -399,7 +586,7 @@ describe("bridgeCodexAppServerStartOptions", () => { await withTempDir("openclaw-codex-computer-use-explicit-home-", async (root) => { const codexHome = path.join(root, "explicit-codex-home"); - await bridgeCodexAppServerStartOptions({ + await reconcileCodexComputerUseStartArtifacts({ startOptions: createStartOptions({ env: { CODEX_HOME: codexHome } }), agentDir: path.join(root, "agent"), pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, @@ -411,12 +598,15 @@ describe("bridgeCodexAppServerStartOptions", () => { }); it("classifies native client provisioning failures as harness preflight", async () => { + computerUseServiceMocks.ensureCodexManagedBundledMarketplace.mockResolvedValueOnce( + "/managed/openai-bundled", + ); computerUseServiceMocks.ensureCodexComputerUseServiceApp.mockRejectedValueOnce( new Error("copy failed"), ); await expect( - bridgeCodexAppServerStartOptions({ + reconcileCodexComputerUseStartArtifacts({ startOptions: createStartOptions(), agentDir: "/tmp/openclaw-codex-computer-use-failed", pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, @@ -424,6 +614,112 @@ describe("bridgeCodexAppServerStartOptions", () => { ).rejects.toMatchObject({ name: "AgentHarnessPreflightError", scope: "harness" }); }); + it("refreshes shared cache once per selected desktop source generation", async () => { + await withTempDir("openclaw-codex-computer-use-cache-owner-", async (agentDir) => { + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache.mockResolvedValue({ + status: "shared", + changed: true, + message: "shared", + removedStaleVersions: [], + warnings: [], + }); + const startOptions = createStartOptions({ + command: "/Applications/ChatGPT.app/Contents/Resources/codex", + }); + const pluginConfig = { + computerUse: { + enabled: true, + autoInstall: false, + pluginCacheMode: "shared" as const, + }, + }; + + await reconcileCodexComputerUseStartArtifacts({ + startOptions, + agentDir, + pluginConfig, + desktopGeneration: { epoch: 1, fingerprint: "desktop-x" }, + }); + await reconcileCodexComputerUseStartArtifacts({ + startOptions, + agentDir, + pluginConfig, + desktopGeneration: { epoch: 1, fingerprint: "desktop-x" }, + }); + await reconcileCodexComputerUseStartArtifacts({ + startOptions, + agentDir, + pluginConfig, + desktopGeneration: { epoch: 2, fingerprint: "desktop-y" }, + }); + + expect( + computerUseServiceMocks.ensureCodexComputerUseSharedPluginCache.mock.calls.map( + ([params]) => params.forceRefresh, + ), + ).toEqual([true, false, true]); + }); + }); + + it("does not let a stale desktop generation publish artifacts after its successor", async () => { + await withTempDir("openclaw-codex-computer-use-generation-", async (agentDir) => { + const firstMarketplaceStarted = createDeferred(); + const releaseFirstMarketplace = createDeferred(); + let activeMarketplaceCalls = 0; + let maxActiveMarketplaceCalls = 0; + computerUseServiceMocks.ensureCodexManagedBundledMarketplace + .mockImplementationOnce(async () => { + activeMarketplaceCalls += 1; + maxActiveMarketplaceCalls = Math.max(maxActiveMarketplaceCalls, activeMarketplaceCalls); + firstMarketplaceStarted.resolve(); + try { + await releaseFirstMarketplace.promise; + return "/managed/openai-bundled"; + } finally { + activeMarketplaceCalls -= 1; + } + }) + .mockImplementationOnce(async () => { + activeMarketplaceCalls += 1; + maxActiveMarketplaceCalls = Math.max(maxActiveMarketplaceCalls, activeMarketplaceCalls); + activeMarketplaceCalls -= 1; + return "/managed/openai-bundled"; + }); + let currentEpoch = 1; + const startOptions = createStartOptions(); + const first = reconcileCodexComputerUseStartArtifacts({ + startOptions, + agentDir, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + desktopGeneration: { epoch: 1, fingerprint: "desktop-x" }, + assertCurrent: () => { + if (currentEpoch !== 1) { + throw new Error("desktop generation X is stale"); + } + }, + }); + await firstMarketplaceStarted.promise; + currentEpoch = 2; + const second = reconcileCodexComputerUseStartArtifacts({ + startOptions, + agentDir, + pluginConfig: { computerUse: { enabled: true, autoInstall: true } }, + desktopGeneration: { epoch: 2, fingerprint: "desktop-y" }, + assertCurrent: () => { + if (currentEpoch !== 2) { + throw new Error("desktop generation Y is stale"); + } + }, + }); + releaseFirstMarketplace.resolve(); + + await expect(first).rejects.toThrow("desktop generation X is stale"); + await expect(second).resolves.toBeUndefined(); + expect(maxActiveMarketplaceCalls).toBe(1); + expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledTimes(1); + }); + }); + it("uses the native user Codex home for coexistence mode", async () => { await withTempDir("openclaw-codex-user-home-", async (root) => { const agentDir = path.join(root, "agent"); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 3662761c6b31..74521676c4f6 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -30,15 +30,25 @@ import { } from "./auth-start-options.js"; import type { CodexAppServerClient } from "./client.js"; import { ensureCodexComputerUseSharedPluginCache } from "./computer-use-cache.js"; -import { ensureCodexManagedBundledMarketplace } from "./computer-use-marketplace.js"; +import { + ensureCodexManagedBundledMarketplace, + resolveCodexManagedBundledMarketplaceSource, +} from "./computer-use-marketplace.js"; import { ensureOwnedCodexHome } from "./computer-use-service-path.js"; -import { ensureCodexComputerUseServiceApp } from "./computer-use-service.js"; +import { + ensureCodexComputerUseServiceApp, + resolveCodexComputerUseServiceAppSourcePath, +} from "./computer-use-service.js"; import { resolveCodexComputerUseConfig, type CodexAppServerHomeScope, type CodexAppServerStartOptions, } from "./config.js"; -import { resolveMacOSDesktopCodexAppPathCandidates } from "./desktop-app-paths.js"; +import { + resolveMacOSDesktopCodexAppPathCandidates, + type MacOSDesktopCodexAppPathCandidate, +} from "./desktop-app-paths.js"; +import type { CodexDesktopGeneration } from "./desktop-generation-owner.js"; import { isJsonObject, type CodexChatgptAuthTokensRefreshResponse, @@ -65,6 +75,11 @@ const CODEX_APP_SERVER_PREPARED_AUTH_ENV_VARS = [ const CODEX_APP_SERVER_HOME_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR]; const CODEX_AUTH_JSON_FILENAME = "auth.json"; const CODEX_HOME_DIRNAME = ".codex"; +const MAX_COMPUTER_USE_ARTIFACT_OWNERS = 128; +const activeComputerUseArtifactReconciliations = new Map< + string, + { latestEpoch?: number; appliedCacheBinding?: string; active: number; tail: Promise } +>(); type AuthProfileOrderConfig = Parameters[0]["cfg"]; export type CodexAppServerAuthRequirement = "api-key" | "subscription"; const scopedOAuthRefreshQueues = new WeakMap< @@ -87,11 +102,7 @@ export async function bridgeCodexAppServerStartOptions(params: { return params.startOptions; } const scopeStartOptions = () => - withCodexHomeEnvironment( - withEphemeralCodexAuthStore(params), - params.agentDir, - params.pluginConfig, - ); + withCodexHomeEnvironment(withEphemeralCodexAuthStore(params), params.agentDir); if (params.preparedAuth) { const scopedStartOptions = await scopeStartOptions(); @@ -518,13 +529,12 @@ export { resolveCodexAppServerHomeDir } from "./auth-start-options.js"; async function withCodexHomeEnvironment( startOptions: CodexAppServerStartOptions, agentDir: string, - pluginConfig?: unknown, ): Promise { const codexHome = resolveCodexAppServerLocalHomeDir(startOptions, agentDir); const nativeHome = startOptions.env?.[HOME_ENV_VAR]?.trim() ? startOptions.env[HOME_ENV_VAR] : undefined; - await reconcileCodexComputerUseStartArtifacts({ startOptions, agentDir, pluginConfig }); + await fs.mkdir(codexHome, { recursive: true }); if (nativeHome) { await fs.mkdir(nativeHome, { recursive: true }); } @@ -551,11 +561,80 @@ export async function reconcileCodexComputerUseStartArtifacts(params: { agentDir: string; pluginConfig?: unknown; ownsIsolatedCodexHome?: boolean; + desktopGeneration?: CodexDesktopGeneration; + assertCurrent?: () => void; + forceCacheRefresh?: boolean; }): Promise { if (params.startOptions.transport !== "stdio") { return; } const codexHome = resolveCodexAppServerLocalHomeDir(params.startOptions, params.agentDir); + const key = path.resolve(codexHome); + let owner = activeComputerUseArtifactReconciliations.get(key); + if (!owner) { + owner = { active: 0, tail: Promise.resolve() }; + activeComputerUseArtifactReconciliations.set(key, owner); + } else { + activeComputerUseArtifactReconciliations.delete(key); + activeComputerUseArtifactReconciliations.set(key, owner); + } + owner.active += 1; + const epoch = params.desktopGeneration?.epoch; + if (epoch !== undefined && (owner.latestEpoch === undefined || epoch > owner.latestEpoch)) { + owner.latestEpoch = epoch; + } + const assertCurrent = () => { + params.assertCurrent?.(); + if (epoch !== undefined && owner.latestEpoch !== epoch) { + throw new Error("Codex Computer Use artifact reconciliation was superseded."); + } + }; + const operation = owner.tail + .catch(() => undefined) + .then(async () => { + assertCurrent(); + const appliedCacheBinding = await reconcileCodexComputerUseStartArtifactsOnce({ + ...params, + codexHome, + assertCurrent, + previousCacheBinding: owner.appliedCacheBinding, + }); + assertCurrent(); + owner.appliedCacheBinding = appliedCacheBinding; + }); + const settled = operation.then( + () => undefined, + () => undefined, + ); + owner.tail = settled; + try { + await operation; + } finally { + owner.active = Math.max(0, owner.active - 1); + if ( + owner.active === 0 && + owner.latestEpoch === undefined && + activeComputerUseArtifactReconciliations.get(key) === owner && + owner.tail === settled + ) { + activeComputerUseArtifactReconciliations.delete(key); + } + pruneComputerUseArtifactOwners(); + } +} + +async function reconcileCodexComputerUseStartArtifactsOnce(params: { + startOptions: CodexAppServerStartOptions; + agentDir: string; + pluginConfig?: unknown; + ownsIsolatedCodexHome?: boolean; + codexHome: string; + assertCurrent: () => void; + desktopGeneration?: CodexDesktopGeneration; + forceCacheRefresh?: boolean; + previousCacheBinding?: string; +}): Promise { + const codexHome = params.codexHome; const computerUseConfig = resolveCodexComputerUseConfig({ pluginConfig: params.pluginConfig }); const ownsIsolatedCodexHome = params.ownsIsolatedCodexHome ?? @@ -573,37 +652,131 @@ export async function reconcileCodexComputerUseStartArtifacts(params: { (candidate) => path.resolve(candidate.appServerCommandPath) === path.resolve(params.startOptions.command), ); - await ensureCodexComputerUseSharedPluginCache({ + const usesManagedBundledMarketplace = + !computerUseConfig.marketplaceSource && + !computerUseConfig.marketplacePath && + !computerUseConfig.marketplaceName; + const needsBundledMarketplace = + usesManagedBundledMarketplace || + (computerUseConfig.pluginCacheMode === "shared" && + !computerUseConfig.marketplaceName && + !computerUseConfig.marketplacePath); + const artifactCandidate = shouldProvisionComputerUse + ? await resolveCompleteComputerUseArtifactCandidate({ + candidates: exactDesktopCandidate ? [exactDesktopCandidate] : desktopCandidates, + needsBundledMarketplace, + }) + : exactDesktopCandidate; + params.assertCurrent(); + if (shouldProvisionComputerUse) { + if (desktopCandidates.length > 0 && !artifactCandidate) { + throw new CodexComputerUseCandidateArtifactsUnavailableError(); + } + try { + const marketplacePath = usesManagedBundledMarketplace + ? await ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: params.agentDir, + ...(artifactCandidate + ? { + appServerCommand: artifactCandidate.appServerCommandPath, + candidates: [artifactCandidate], + ownershipCandidates: desktopCandidates, + } + : {}), + assertCurrent: params.assertCurrent, + }) + : undefined; + params.assertCurrent(); + if (usesManagedBundledMarketplace && desktopCandidates.length > 0 && !marketplacePath) { + throw new CodexComputerUseCandidateArtifactsUnavailableError(); + } + const service = await ensureCodexComputerUseServiceApp({ + codexHome, + ownershipRoot: params.agentDir, + ...(artifactCandidate + ? { + appServerCommand: artifactCandidate.appServerCommandPath, + sourceAppCandidates: artifactCandidate.computerUseServiceAppPaths, + } + : {}), + assertCurrent: params.assertCurrent, + }); + params.assertCurrent(); + if (desktopCandidates.length > 0 && service.status === "source_missing") { + throw new CodexComputerUseCandidateArtifactsUnavailableError(); + } + } catch (error) { + params.assertCurrent(); + if (error instanceof CodexComputerUseCandidateArtifactsUnavailableError) { + throw error; + } + throw new AgentHarnessPreflightError("Codex Computer Use client provisioning failed.", { + cause: error, + scope: "harness", + }); + } + } + params.assertCurrent(); + const cacheBinding = [ + params.desktopGeneration?.epoch ?? "manual", + artifactCandidate?.bundledMarketplacePath ?? "default", + computerUseConfig.pluginName, + ].join("\0"); + const cache = await ensureCodexComputerUseSharedPluginCache({ codexHome, config: computerUseConfig, ...(ownsIsolatedCodexHome ? { ownershipRoot: params.agentDir } : {}), - ...(exactDesktopCandidate - ? { bundledMarketplacePath: exactDesktopCandidate.bundledMarketplacePath } + ...(artifactCandidate + ? { bundledMarketplacePath: artifactCandidate.bundledMarketplacePath } : {}), + assertCurrent: params.assertCurrent, + forceRefresh: params.forceCacheRefresh === true || params.previousCacheBinding !== cacheBinding, }); - if (!shouldProvisionComputerUse) { - return; + params.assertCurrent(); + return cache.status === "shared" ? cacheBinding : undefined; +} + +async function resolveCompleteComputerUseArtifactCandidate(params: { + candidates: readonly MacOSDesktopCodexAppPathCandidate[]; + needsBundledMarketplace: boolean; +}): Promise { + for (const candidate of params.candidates) { + if ( + params.needsBundledMarketplace && + !(await resolveCodexManagedBundledMarketplaceSource({ candidates: [candidate] })) + ) { + continue; + } + if ( + await resolveCodexComputerUseServiceAppSourcePath({ + sourceAppCandidates: candidate.computerUseServiceAppPaths, + }) + ) { + return candidate; + } } - try { - await ensureCodexManagedBundledMarketplace({ - codexHome, - ownershipRoot: params.agentDir, - appServerCommand: params.startOptions.command, - ...(exactDesktopCandidate ? { candidates: [exactDesktopCandidate] } : {}), - }); - await ensureCodexComputerUseServiceApp({ - codexHome, - ownershipRoot: params.agentDir, - appServerCommand: params.startOptions.command, - ...(exactDesktopCandidate - ? { sourceAppCandidates: exactDesktopCandidate.computerUseServiceAppPaths } - : {}), - }); - } catch (error) { - throw new AgentHarnessPreflightError("Codex Computer Use client provisioning failed.", { - cause: error, - scope: "harness", - }); + return undefined; +} + +function pruneComputerUseArtifactOwners(): void { + while (activeComputerUseArtifactReconciliations.size > MAX_COMPUTER_USE_ARTIFACT_OWNERS) { + const inactive = [...activeComputerUseArtifactReconciliations].find( + ([, owner]) => owner.active === 0, + ); + if (!inactive) { + return; + } + activeComputerUseArtifactReconciliations.delete(inactive[0]); + } +} + +class CodexComputerUseCandidateArtifactsUnavailableError extends Error { + readonly code = "CODEX_COMPUTER_USE_CANDIDATE_ARTIFACTS_UNAVAILABLE"; + + constructor() { + super("The selected Codex desktop app does not contain complete Computer Use artifacts."); + this.name = "CodexComputerUseCandidateArtifactsUnavailableError"; } } diff --git a/extensions/codex/src/app-server/computer-use-cache.test.ts b/extensions/codex/src/app-server/computer-use-cache.test.ts index ca99f4363afd..aff044d11c55 100644 --- a/extensions/codex/src/app-server/computer-use-cache.test.ts +++ b/extensions/codex/src/app-server/computer-use-cache.test.ts @@ -179,6 +179,90 @@ describe("Codex Computer Use shared plugin cache", () => { ).resolves.toBe(undefined); }); + it("refreshes same-version cache bytes for a new desktop generation", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-generation-"); + const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await writeBundledComputerUsePlugin(bundledMarketplacePath, "1.0.857"); + await fs.writeFile(path.join(bundledPluginRoot, "generation.txt"), "generation-y"); + const codexHome = path.join(root, "agent", "codex-home"); + const activeCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.857", + ); + await fs.mkdir(path.join(activeCachePath, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(activeCachePath, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.857" }), + ); + await fs.writeFile(path.join(activeCachePath, "generation.txt"), "generation-x"); + + const result = await ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + forceRefresh: true, + }); + + expect(result).toMatchObject({ status: "shared", changed: true, version: "1.0.857" }); + await expect(fs.readFile(path.join(activeCachePath, "generation.txt"), "utf8")).resolves.toBe( + "generation-y", + ); + }); + + it("leaves same-version cache bytes intact when the generation is stale before publication", async () => { + const root = tempDirs.make("openclaw-computer-use-cache-stale-"); + const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); + const bundledPluginRoot = path.join(bundledMarketplacePath, "plugins", "computer-use"); + await writeBundledComputerUsePlugin(bundledMarketplacePath, "1.0.857"); + await fs.writeFile(path.join(bundledPluginRoot, "generation.txt"), "generation-y"); + const codexHome = path.join(root, "agent", "codex-home"); + const activeCachePath = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "computer-use", + "1.0.857", + ); + await fs.mkdir(path.join(activeCachePath, ".codex-plugin"), { recursive: true }); + await fs.writeFile( + path.join(activeCachePath, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "computer-use", version: "1.0.857" }), + ); + await fs.writeFile(path.join(activeCachePath, "generation.txt"), "generation-x"); + + let currentnessChecks = 0; + await expect( + ensureCodexComputerUseSharedPluginCache({ + codexHome, + bundledMarketplacePath, + config: computerUseConfig(), + forceRefresh: true, + assertCurrent: () => { + currentnessChecks += 1; + if (currentnessChecks === 2) { + throw new Error("desktop generation is stale"); + } + }, + }), + ).rejects.toThrow("desktop generation is stale"); + expect(currentnessChecks).toBe(2); + + await expect(fs.readFile(path.join(activeCachePath, "generation.txt"), "utf8")).resolves.toBe( + "generation-x", + ); + expect( + (await fs.readdir(path.dirname(activeCachePath))).filter((entry) => + entry.startsWith(".1.0.857"), + ), + ).toEqual([]); + }); + it("refreshes a stale copied cache entry with the bundled version", async () => { const root = tempDirs.make("openclaw-computer-use-cache-"); const bundledMarketplacePath = path.join(root, "Codex.app", "plugins", "openai-bundled"); diff --git a/extensions/codex/src/app-server/computer-use-cache.ts b/extensions/codex/src/app-server/computer-use-cache.ts index b95e3440a5a3..b705caa83016 100644 --- a/extensions/codex/src/app-server/computer-use-cache.ts +++ b/extensions/codex/src/app-server/computer-use-cache.ts @@ -41,6 +41,8 @@ export async function ensureCodexComputerUseSharedPluginCache(params: { bundledMarketplacePath?: string; bundledMarketplacePathCandidates?: readonly string[]; ownershipRoot?: string; + assertCurrent?: () => void; + forceRefresh?: boolean; }): Promise { if (!params.config.enabled) { return skippedCacheResult( @@ -83,6 +85,8 @@ export async function ensureCodexComputerUseSharedPluginCache(params: { const changed = await ensureRealDirectoryCopy(cachePath, sourcePluginRoot, version, { codexHome: params.codexHome, ownershipRoot: params.ownershipRoot, + assertCurrent: params.assertCurrent, + forceRefresh: params.forceRefresh, }); return { status: "shared", @@ -132,7 +136,12 @@ async function ensureRealDirectoryCopy( cachePath: string, sourcePluginRoot: string, version: string, - boundary: { codexHome: string; ownershipRoot?: string }, + boundary: { + codexHome: string; + ownershipRoot?: string; + assertCurrent?: () => void; + forceRefresh?: boolean; + }, ): Promise { const cacheRoot = path.dirname(cachePath); const ownedParent = boundary.ownershipRoot @@ -151,7 +160,7 @@ async function ensureRealDirectoryCopy( const stat = await fs.lstat(physicalCachePath).catch(() => undefined); if (stat?.isDirectory() && !stat.isSymbolicLink()) { const cachedVersion = await readBundledPluginVersion(physicalCachePath); - if (cachedVersion === version) { + if (cachedVersion === version && !boundary.forceRefresh) { return false; } } @@ -170,6 +179,7 @@ async function ensureRealDirectoryCopy( await assertDirectoryIdentityStable(ownedParent, "Computer Use plugin cache parent"); } if (stat) { + boundary.assertCurrent?.(); await fs.rename(physicalCachePath, backupPath); backupCreated = true; } @@ -177,6 +187,7 @@ async function ensureRealDirectoryCopy( if (ownedParent) { await assertDirectoryIdentityStable(ownedParent, "Computer Use plugin cache parent"); } + boundary.assertCurrent?.(); await fs.rename(stagedPath, physicalCachePath); } catch (error) { if (backupCreated) { diff --git a/extensions/codex/src/app-server/computer-use-marketplace.test.ts b/extensions/codex/src/app-server/computer-use-marketplace.test.ts index c1e98bc6ee93..e81914cffc42 100644 --- a/extensions/codex/src/app-server/computer-use-marketplace.test.ts +++ b/extensions/codex/src/app-server/computer-use-marketplace.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureCodexManagedBundledMarketplace, @@ -11,6 +12,10 @@ import { useAutoCleanupTempDirTracker } from "./test-support.js"; describe("managed Codex bundled marketplace", () => { const tempDirs = useAutoCleanupTempDirTracker(afterEach); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("publishes a real reserved root with links to the selected desktop marketplace", async () => { const root = tempDirs.make("openclaw-codex-marketplace-"); const candidate = await writeCandidate(root); @@ -68,6 +73,189 @@ describe("managed Codex bundled marketplace", () => { ).toEqual([]); }); + it("converges a concurrent replacement to the newly selected desktop source", async () => { + const root = tempDirs.make("openclaw-codex-marketplace-transition-"); + const firstCandidate = await writeCandidate(path.join(root, "first")); + const secondCandidate = await writeCandidate(path.join(root, "second")); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(agentDir, "codex-home"); + const target = resolveCodexManagedBundledMarketplacePath(codexHome); + const firstPublishStarted = createDeferred(); + const releaseFirstPublish = createDeferred(); + const rename = fs.rename.bind(fs); + let heldFirstPublish = false; + vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (!heldFirstPublish && destination === target) { + heldFirstPublish = true; + firstPublishStarted.resolve(); + await releaseFirstPublish.promise; + } + return await rename(source, destination); + }); + + const first = ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: firstCandidate.appServerCommandPath, + candidates: [firstCandidate], + ownershipCandidates: [firstCandidate, secondCandidate], + }); + await firstPublishStarted.promise; + const second = ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: secondCandidate.appServerCommandPath, + candidates: [secondCandidate], + ownershipCandidates: [firstCandidate, secondCandidate], + }); + releaseFirstPublish.resolve(); + + await expect(Promise.all([first, second])).resolves.toEqual([target, target]); + expect(await fs.readlink(path.join(target, "plugins"))).toBe( + path.join(secondCandidate.bundledMarketplacePath, "plugins"), + ); + expect( + (await fs.readdir(path.dirname(target))).filter((entry) => + entry.startsWith(".openai-bundled"), + ), + ).toEqual([]); + }); + + it("does not let a matching fast path outrun a conflicting publication", async () => { + const root = tempDirs.make("openclaw-codex-marketplace-conflict-"); + const firstCandidate = await writeCandidate(path.join(root, "first")); + const secondCandidate = await writeCandidate(path.join(root, "second")); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(agentDir, "codex-home"); + const target = resolveCodexManagedBundledMarketplacePath(codexHome); + const ownershipCandidates = [firstCandidate, secondCandidate]; + await ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + candidates: [secondCandidate], + ownershipCandidates, + }); + + const firstStagingStarted = createDeferred(); + const releaseFirstStaging = createDeferred(); + const createStagingDir = fs.mkdtemp.bind(fs); + vi.spyOn(fs, "mkdtemp").mockImplementationOnce(async (prefix, options) => { + firstStagingStarted.resolve(); + await releaseFirstStaging.promise; + return await createStagingDir(prefix, options); + }); + const first = ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + candidates: [firstCandidate], + ownershipCandidates, + }); + await firstStagingStarted.promise; + const lstat = fs.lstat.bind(fs); + let firstReleased = false; + let targetChecksBeforeRelease = 0; + vi.spyOn(fs, "lstat").mockImplementation(async (filePath, options) => { + if (path.resolve(String(filePath)) === target && !firstReleased) { + targetChecksBeforeRelease += 1; + if (targetChecksBeforeRelease > 1) { + throw new Error("conflicting wrapper fast path ran before active publication settled"); + } + setImmediate(() => { + firstReleased = true; + releaseFirstStaging.resolve(); + }); + } + return await lstat(filePath, options); + }); + const second = ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + candidates: [secondCandidate], + ownershipCandidates, + }); + const results = await Promise.allSettled([first, second]); + + expect(targetChecksBeforeRelease).toBe(1); + expect(results).toEqual([ + { status: "fulfilled", value: target }, + { status: "fulfilled", value: target }, + ]); + expect(await fs.readlink(path.join(target, "plugins"))).toBe( + path.join(secondCandidate.bundledMarketplacePath, "plugins"), + ); + }); + + it("replaces a prior owned wrapper when desktop app selection changes", async () => { + const root = tempDirs.make("openclaw-codex-marketplace-owner-transition-"); + const firstCandidate = await writeCandidate(path.join(root, "first")); + const secondCandidate = await writeCandidate(path.join(root, "second")); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(agentDir, "codex-home"); + const target = resolveCodexManagedBundledMarketplacePath(codexHome); + const ownershipCandidates = [firstCandidate, secondCandidate]; + + await ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: firstCandidate.appServerCommandPath, + candidates: [firstCandidate], + ownershipCandidates, + }); + await ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: secondCandidate.appServerCommandPath, + candidates: [secondCandidate], + ownershipCandidates, + }); + + expect(await fs.readlink(path.join(target, "plugins"))).toBe( + path.join(secondCandidate.bundledMarketplacePath, "plugins"), + ); + }); + + it("leaves the prior wrapper intact when its generation becomes stale before publication", async () => { + const root = tempDirs.make("openclaw-codex-marketplace-stale-"); + const firstCandidate = await writeCandidate(path.join(root, "first")); + const secondCandidate = await writeCandidate(path.join(root, "second")); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(agentDir, "codex-home"); + const target = resolveCodexManagedBundledMarketplacePath(codexHome); + const ownershipCandidates = [firstCandidate, secondCandidate]; + await ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + candidates: [firstCandidate], + ownershipCandidates, + }); + + let currentnessChecks = 0; + await expect( + ensureCodexManagedBundledMarketplace({ + codexHome, + ownershipRoot: agentDir, + candidates: [secondCandidate], + ownershipCandidates, + assertCurrent: () => { + currentnessChecks += 1; + if (currentnessChecks === 2) { + throw new Error("desktop generation is stale"); + } + }, + }), + ).rejects.toThrow("desktop generation is stale"); + expect(currentnessChecks).toBe(2); + + expect(await fs.readlink(path.join(target, "plugins"))).toBe( + path.join(firstCandidate.bundledMarketplacePath, "plugins"), + ); + expect( + (await fs.readdir(path.dirname(target))).filter((entry) => + entry.startsWith(".openai-bundled"), + ), + ).toEqual([]); + }); + it("does not replace an unowned directory at the reserved managed path", async () => { const root = tempDirs.make("openclaw-codex-marketplace-unowned-"); const candidate = await writeCandidate(root); diff --git a/extensions/codex/src/app-server/computer-use-marketplace.ts b/extensions/codex/src/app-server/computer-use-marketplace.ts index 65fdd632f675..90798e4a3b50 100644 --- a/extensions/codex/src/app-server/computer-use-marketplace.ts +++ b/extensions/codex/src/app-server/computer-use-marketplace.ts @@ -14,7 +14,10 @@ import { } from "./desktop-app-paths.js"; const MARKETPLACE_NAME = "openai-bundled"; -const activeInstalls = new Map>(); +const activeInstalls = new Map< + string, + { sourcePath: string; promise: Promise } +>(); export function resolveCodexManagedBundledMarketplacePath(codexHome: string): string { return path.join(codexHome, ".tmp", "bundled-marketplaces", MARKETPLACE_NAME); @@ -25,9 +28,11 @@ export async function ensureCodexManagedBundledMarketplace(params: { ownershipRoot: string; appServerCommand?: string; candidates?: readonly MacOSDesktopCodexAppPathCandidate[]; + ownershipCandidates?: readonly MacOSDesktopCodexAppPathCandidate[]; + assertCurrent?: () => void; }): Promise { const candidates = params.candidates ?? resolveMacOSDesktopCodexAppPathCandidates(); - const source = await resolveSource({ ...params, candidates }); + const source = await resolveCodexManagedBundledMarketplaceSource({ ...params, candidates }); if (!source) { return undefined; } @@ -40,24 +45,26 @@ export async function ensureCodexManagedBundledMarketplace(params: { }); const physicalTargetPath = path.join(parent.realPath, MARKETPLACE_NAME); await assertNotSymlink(physicalTargetPath, "managed bundled marketplace"); - if (await wrapperMatches(physicalTargetPath, source.bundledMarketplacePath)) { - return targetPath; - } const active = activeInstalls.get(physicalTargetPath); if (active) { - return await active; + if (active.sourcePath === source.bundledMarketplacePath) { + return await active.promise; + } + await active.promise.catch(() => undefined); + return await ensureCodexManagedBundledMarketplace(params); } - - const install = publishManagedWrapper({ + const install = reconcileManagedWrapper({ parent, physicalTargetPath, targetPath, source, - candidates, + ownershipCandidates: params.ownershipCandidates ?? candidates, + assertCurrent: params.assertCurrent, }); - activeInstalls.set(physicalTargetPath, install); + const activeEntry = { sourcePath: source.bundledMarketplacePath, promise: install }; + activeInstalls.set(physicalTargetPath, activeEntry); const clearActive = () => { - if (activeInstalls.get(physicalTargetPath) === install) { + if (activeInstalls.get(physicalTargetPath) === activeEntry) { activeInstalls.delete(physicalTargetPath); } }; @@ -65,14 +72,25 @@ export async function ensureCodexManagedBundledMarketplace(params: { return await install; } +async function reconcileManagedWrapper( + params: Parameters[0], +): Promise { + if (await wrapperMatches(params.physicalTargetPath, params.source.bundledMarketplacePath)) { + return params.targetPath; + } + return await publishManagedWrapper(params); +} + async function publishManagedWrapper(params: { parent: Awaited>; physicalTargetPath: string; targetPath: string; source: MacOSDesktopCodexAppPathCandidate; - candidates: readonly MacOSDesktopCodexAppPathCandidate[]; + ownershipCandidates: readonly MacOSDesktopCodexAppPathCandidate[]; + assertCurrent?: () => void; }): Promise { - const { parent, physicalTargetPath, targetPath, source, candidates } = params; + const { parent, physicalTargetPath, targetPath, source, ownershipCandidates, assertCurrent } = + params; const stagingPath = await fs.mkdtemp(path.join(parent.realPath, `.${MARKETPLACE_NAME}.staging-`)); const backupPath = path.join( @@ -103,16 +121,18 @@ async function publishManagedWrapper(params: { if (existing && !existing.isDirectory()) { throw new Error(`Managed bundled marketplace must be a real directory: ${targetPath}`); } - if (existing && !(await wrapperMatchesAnySource(physicalTargetPath, candidates))) { + if (existing && !(await wrapperMatchesAnySource(physicalTargetPath, ownershipCandidates))) { throw new Error( `Refusing to replace an unowned bundled marketplace directory: ${targetPath}`, ); } if (existing) { + assertCurrent?.(); await fs.rename(physicalTargetPath, backupPath); backupCreated = true; await assertDirectoryIdentityStable(parent, "managed bundled marketplace parent"); } + assertCurrent?.(); await fs.rename(stagingPath, physicalTargetPath); await assertDirectoryIdentityStable(parent, "managed bundled marketplace parent"); if (backupCreated) { @@ -152,21 +172,14 @@ async function publishManagedWrapper(params: { } } -async function resolveSource(params: { +export async function resolveCodexManagedBundledMarketplaceSource(params: { appServerCommand?: string; candidates?: readonly MacOSDesktopCodexAppPathCandidate[]; }): Promise { const candidates = params.candidates ?? resolveMacOSDesktopCodexAppPathCandidates(); const command = params.appServerCommand && path.resolve(params.appServerCommand); const ordered = command - ? [ - ...candidates.filter( - (candidate) => path.resolve(candidate.appServerCommandPath) === command, - ), - ...candidates.filter( - (candidate) => path.resolve(candidate.appServerCommandPath) !== command, - ), - ] + ? candidates.filter((candidate) => path.resolve(candidate.appServerCommandPath) === command) : candidates; for (const candidate of ordered) { if (await isExpectedMarketplace(candidate.bundledMarketplacePath)) { diff --git a/extensions/codex/src/app-server/computer-use-service.test.ts b/extensions/codex/src/app-server/computer-use-service.test.ts index 7be792fa7566..19e8938399ce 100644 --- a/extensions/codex/src/app-server/computer-use-service.test.ts +++ b/extensions/codex/src/app-server/computer-use-service.test.ts @@ -595,6 +595,44 @@ describe("Codex Computer Use native service", () => { await expect(inspectServiceFixture(targetPath)).resolves.toEqual(CURRENT_IDENTITY); }); + it("leaves the prior service intact when its generation becomes stale before publication", async () => { + const root = tempDirs.make("openclaw-computer-use-service-stale-"); + const firstSourcePath = path.join(root, "first", "Codex Computer Use.app"); + const secondSourcePath = path.join(root, "second", "Codex Computer Use.app"); + const codexHome = path.join(root, "codex-home"); + const targetPath = path.join(codexHome, "computer-use", "Codex Computer Use.app"); + await writeServiceFixture(firstSourcePath, CURRENT_IDENTITY); + await writeServiceFixture(secondSourcePath, UNEXPECTED_IDENTITY); + await ensureCodexComputerUseServiceApp({ + codexHome, + platform: "darwin", + sourceAppCandidates: [firstSourcePath], + copyServiceApp: copyServiceFixture, + inspectServiceApp: inspectServiceFixture, + }); + + let currentnessChecks = 0; + await expect( + ensureCodexComputerUseServiceApp({ + codexHome, + platform: "darwin", + sourceAppCandidates: [secondSourcePath], + copyServiceApp: copyServiceFixture, + inspectServiceApp: inspectServiceFixture, + assertCurrent: () => { + currentnessChecks += 1; + if (currentnessChecks === 2) { + throw new Error("desktop generation is stale"); + } + }, + }), + ).rejects.toThrow("desktop generation is stale"); + expect(currentnessChecks).toBe(2); + + await expect(inspectServiceFixture(targetPath)).resolves.toEqual(CURRENT_IDENTITY); + await expect(findInstallDebris(path.dirname(targetPath))).resolves.toEqual([]); + }); + it("does not provision the macOS service on other platforms", async () => { const inspectServiceApp = vi.fn(); const result = await ensureCodexComputerUseServiceApp({ diff --git a/extensions/codex/src/app-server/computer-use-service.ts b/extensions/codex/src/app-server/computer-use-service.ts index 2c41dba20346..0e1bf8a44296 100644 --- a/extensions/codex/src/app-server/computer-use-service.ts +++ b/extensions/codex/src/app-server/computer-use-service.ts @@ -64,6 +64,25 @@ type ServiceAppSnapshot = { filesystemKey?: string; }; +/** Finds the first signed native service from one ordered desktop owner set. */ +export async function resolveCodexComputerUseServiceAppSourcePath(params: { + platform?: NodeJS.Platform; + appServerCommand?: string; + sourceAppCandidates?: readonly string[]; + inspectServiceApp?: InspectServiceApp; +}): Promise { + const platform = params.platform ?? process.platform; + if (platform !== "darwin") { + return undefined; + } + const candidates = + params.sourceAppCandidates ?? + resolveMacOSDesktopCodexComputerUseServiceAppCandidates(platform, params.appServerCommand); + return ( + await findUsableServiceApp(candidates, params.inspectServiceApp ?? inspectTrustedServiceApp) + )?.path; +} + /** Synchronizes the CODEX_HOME native client with the selected signed desktop distribution. */ export async function ensureCodexComputerUseServiceApp(params: { codexHome: string; @@ -73,6 +92,7 @@ export async function ensureCodexComputerUseServiceApp(params: { sourceAppCandidates?: readonly string[]; copyServiceApp?: CopyServiceApp; inspectServiceApp?: InspectServiceApp; + assertCurrent?: () => void; }): Promise { const platform = params.platform ?? process.platform; if (platform !== "darwin") { @@ -126,6 +146,7 @@ async function ensureCodexComputerUseServiceAppOnce(params: { sourceAppCandidates?: readonly string[]; copyServiceApp?: CopyServiceApp; inspectServiceApp?: InspectServiceApp; + assertCurrent?: () => void; }): Promise { const inspectServiceApp = params.inspectServiceApp ?? inspectTrustedServiceApp; const candidates = params.sourceAppCandidates ?? []; @@ -187,6 +208,7 @@ async function ensureCodexComputerUseServiceAppOnce(params: { await assertNotSymlink(operationTargetPath, "Computer Use service target"); if (await pathExists(operationTargetPath)) { await assertOwnedServiceParentStable(ownedParent); + params.assertCurrent?.(); await fs.rename(operationTargetPath, backupPath); await assertOwnedServiceParentStable(ownedParent); backupCreated = true; @@ -218,6 +240,7 @@ async function ensureCodexComputerUseServiceAppOnce(params: { } try { await assertOwnedServiceParentStable(ownedParent); + params.assertCurrent?.(); await fs.rename(stagedPath, operationTargetPath); await assertOwnedServiceParentStable(ownedParent); } catch (error) { diff --git a/extensions/codex/src/app-server/computer-use.test.ts b/extensions/codex/src/app-server/computer-use.test.ts index 845974574f24..6b5e4e17cd1b 100644 --- a/extensions/codex/src/app-server/computer-use.test.ts +++ b/extensions/codex/src/app-server/computer-use.test.ts @@ -10,13 +10,28 @@ import { createClientHarness, useAutoCleanupTempDirTracker } from "./test-suppor const requestCodexAppServerJsonMock = vi.hoisted(() => vi.fn()); const sharedClientMocks = vi.hoisted(() => ({ + assertCodexAppServerClientStartSelectionCurrent: vi.fn(), getLeasedSharedCodexAppServerClient: vi.fn(), + readCodexAppServerClientDesktopGeneration: vi.fn(), readCodexAppServerClientProcessIdentity: vi.fn(), releaseLeasedSharedCodexAppServerClient: vi.fn(), })); const managedProvisioningMocks = vi.hoisted(() => ({ + ensureCodexComputerUseSharedPluginCache: vi.fn(async () => ({ + status: "independent" as const, + changed: false, + message: "independent", + removedStaleVersions: [], + warnings: [], + })), ensureCodexManagedBundledMarketplace: vi.fn(), ensureCodexComputerUseServiceApp: vi.fn(), + resolveCodexManagedBundledMarketplaceSource: vi.fn( + async (params: { candidates?: readonly unknown[] }) => params.candidates?.[0], + ), + resolveCodexComputerUseServiceAppSourcePath: vi.fn( + async (params: { sourceAppCandidates?: readonly string[] }) => params.sourceAppCandidates?.[0], + ), })); vi.mock("./request.js", () => ({ @@ -32,11 +47,20 @@ vi.mock("./computer-use-marketplace.js", async (importOriginal) => ({ ...(await importOriginal()), ensureCodexManagedBundledMarketplace: managedProvisioningMocks.ensureCodexManagedBundledMarketplace, + resolveCodexManagedBundledMarketplaceSource: + managedProvisioningMocks.resolveCodexManagedBundledMarketplaceSource, })); vi.mock("./computer-use-service.js", async (importOriginal) => ({ ...(await importOriginal()), ensureCodexComputerUseServiceApp: managedProvisioningMocks.ensureCodexComputerUseServiceApp, + resolveCodexComputerUseServiceAppSourcePath: + managedProvisioningMocks.resolveCodexComputerUseServiceAppSourcePath, +})); + +vi.mock("./computer-use-cache.js", () => ({ + ensureCodexComputerUseSharedPluginCache: + managedProvisioningMocks.ensureCodexComputerUseSharedPluginCache, })); import { @@ -95,11 +119,30 @@ describe("Codex Computer Use setup", () => { afterEach(() => { vi.useRealTimers(); requestCodexAppServerJsonMock.mockReset(); + sharedClientMocks.assertCodexAppServerClientStartSelectionCurrent.mockReset(); sharedClientMocks.getLeasedSharedCodexAppServerClient.mockReset(); + sharedClientMocks.readCodexAppServerClientDesktopGeneration.mockReset(); sharedClientMocks.readCodexAppServerClientProcessIdentity.mockReset(); sharedClientMocks.releaseLeasedSharedCodexAppServerClient.mockReset(); managedProvisioningMocks.ensureCodexManagedBundledMarketplace.mockReset(); managedProvisioningMocks.ensureCodexComputerUseServiceApp.mockReset(); + managedProvisioningMocks.ensureCodexComputerUseSharedPluginCache.mockReset(); + managedProvisioningMocks.ensureCodexComputerUseSharedPluginCache.mockResolvedValue({ + status: "independent", + changed: false, + message: "independent", + removedStaleVersions: [], + warnings: [], + }); + managedProvisioningMocks.resolveCodexManagedBundledMarketplaceSource.mockReset(); + managedProvisioningMocks.resolveCodexManagedBundledMarketplaceSource.mockImplementation( + async (params: { candidates?: readonly unknown[] }) => params.candidates?.[0], + ); + managedProvisioningMocks.resolveCodexComputerUseServiceAppSourcePath.mockReset(); + managedProvisioningMocks.resolveCodexComputerUseServiceAppSourcePath.mockImplementation( + async (params: { sourceAppCandidates?: readonly string[] }) => + params.sourceAppCandidates?.[0], + ); }); it("stays disabled until configured", async () => { @@ -1016,6 +1059,13 @@ describe("Codex Computer Use setup", () => { commandSource, argsFingerprint: "args", }); + managedProvisioningMocks.ensureCodexManagedBundledMarketplace.mockResolvedValue( + managedMarketplacePath, + ); + managedProvisioningMocks.ensureCodexComputerUseServiceApp.mockResolvedValue({ + status: "already_current", + changed: false, + }); const request = createBundledMarketplaceComputerUseRequest(managedMarketplacePath); const status = await installCodexComputerUse({ @@ -1026,19 +1076,68 @@ describe("Codex Computer Use setup", () => { }); expect(status.ready).toBe(true); - expect(managedProvisioningMocks.ensureCodexManagedBundledMarketplace).toHaveBeenCalledWith({ - codexHome, - ownershipRoot: agentDir, - appServerCommand: "/Applications/ChatGPT.app/Contents/Resources/codex", - }); - expect(managedProvisioningMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith({ - codexHome, - ownershipRoot: agentDir, - appServerCommand: "/Applications/ChatGPT.app/Contents/Resources/codex", - }); + expect(managedProvisioningMocks.ensureCodexManagedBundledMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: "/Applications/ChatGPT.app/Contents/Resources/codex", + }), + ); + expect(managedProvisioningMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome, + ownershipRoot: agentDir, + appServerCommand: "/Applications/ChatGPT.app/Contents/Resources/codex", + }), + ); + expect(sharedClientMocks.assertCodexAppServerClientStartSelectionCurrent).toHaveBeenCalled(); + expect(sharedClientMocks.readCodexAppServerClientDesktopGeneration).toHaveReturnedWith( + undefined, + ); + expect(managedProvisioningMocks.ensureCodexComputerUseSharedPluginCache).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true }), + ); }, ); + it("rejects explicit provisioning from a stale desktop client", async () => { + const root = tempDirs.make("openclaw-codex-explicit-install-stale-"); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(agentDir, "codex-home"); + fs.mkdirSync(codexHome, { recursive: true }); + const harness = createClientHarness(); + vi.spyOn(harness.client, "getRuntimeIdentity").mockReturnValue({ + serverVersion: "0.148.0", + codexHome, + }); + sharedClientMocks.readCodexAppServerClientProcessIdentity.mockReturnValue({ + clientId: "client-explicit-install-stale", + command: "/Applications/ChatGPT.app/Contents/Resources/codex", + commandSource: "resolved-managed", + argsFingerprint: "args", + }); + sharedClientMocks.readCodexAppServerClientDesktopGeneration.mockReturnValue({ + epoch: 1, + fingerprint: "desktop-x", + }); + sharedClientMocks.assertCodexAppServerClientStartSelectionCurrent.mockImplementation(() => { + throw Object.assign(new Error("desktop selection changed"), { + code: "CODEX_APP_SERVER_START_SELECTION_CHANGED", + }); + }); + + await expect( + installCodexComputerUse({ + agentDir, + client: harness.client, + request: vi.fn(), + pluginConfig: { computerUse: { enabled: true, autoInstall: false } }, + }), + ).rejects.toThrow("desktop selection changed"); + expect(managedProvisioningMocks.ensureCodexManagedBundledMarketplace).not.toHaveBeenCalled(); + expect(managedProvisioningMocks.ensureCodexComputerUseServiceApp).not.toHaveBeenCalled(); + }); + it("allows auto-install from a configured local marketplace path", async () => { const request = createComputerUseRequest({ installed: false }); diff --git a/extensions/codex/src/app-server/computer-use.ts b/extensions/codex/src/app-server/computer-use.ts index ea46c96c470c..461f3858b6aa 100644 --- a/extensions/codex/src/app-server/computer-use.ts +++ b/extensions/codex/src/app-server/computer-use.ts @@ -5,6 +5,7 @@ import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { reconcileCodexComputerUseStartArtifacts } from "./auth-bridge.js"; import { resolveCodexAppServerHomeDir } from "./auth-start-options.js"; import { describeControlFailure } from "./capabilities.js"; import { @@ -13,17 +14,13 @@ import { isCodexAppServerIndeterminateTransportError, type CodexAppServerClient, } from "./client.js"; -import { - ensureCodexManagedBundledMarketplace, - resolveCodexManagedBundledMarketplacePath, -} from "./computer-use-marketplace.js"; +import { resolveCodexManagedBundledMarketplacePath } from "./computer-use-marketplace.js"; import { killStaleComputerUseMcpChildren, scopedRepairUnavailableStatus, type CodexComputerUseRepairStatus, } from "./computer-use-process-repair.js"; import { assertNotSymlink } from "./computer-use-service-path.js"; -import { ensureCodexComputerUseServiceApp } from "./computer-use-service.js"; import { resolveCodexAppServerRuntimeOptions, resolveCodexComputerUseConfig, @@ -45,7 +42,9 @@ import type { } from "./protocol.js"; import { requestCodexAppServerJson } from "./request.js"; import { + assertCodexAppServerClientStartSelectionCurrent, getLeasedSharedCodexAppServerClient, + readCodexAppServerClientDesktopGeneration, readCodexAppServerClientProcessIdentity, releaseLeasedSharedCodexAppServerClient, resolveCodexNativeConfigFenceKey, @@ -453,8 +452,9 @@ async function prepareExplicitManagedComputerUseInstall( ) { return; } - const codexHome = params.client.getRuntimeIdentity()?.codexHome; - const processIdentity = readCodexAppServerClientProcessIdentity(params.client); + const client = params.client; + const codexHome = client.getRuntimeIdentity()?.codexHome; + const processIdentity = readCodexAppServerClientProcessIdentity(client); const command = processIdentity?.nativeCommand ?? (processIdentity && isManagedCodexDesktopCommand(processIdentity.command, "darwin") @@ -463,6 +463,7 @@ async function prepareExplicitManagedComputerUseInstall( if (!codexHome || !command) { return; } + const desktopGeneration = readCodexAppServerClientDesktopGeneration(client); const expectedHome = resolveCodexAppServerHomeDir(params.agentDir); const [actualRealHome, expectedRealHome] = await Promise.all([ fs.realpath(codexHome).catch(() => undefined), @@ -471,15 +472,21 @@ async function prepareExplicitManagedComputerUseInstall( if (!actualRealHome || actualRealHome !== expectedRealHome) { return; } - await ensureCodexManagedBundledMarketplace({ - codexHome, - ownershipRoot: params.agentDir, - appServerCommand: command, - }); - await ensureCodexComputerUseServiceApp({ - codexHome, - ownershipRoot: params.agentDir, - appServerCommand: command, + await reconcileCodexComputerUseStartArtifacts({ + startOptions: { + transport: "stdio", + command, + commandSource: "resolved-managed", + args: ["app-server"], + headers: {}, + env: { CODEX_HOME: codexHome }, + }, + agentDir: params.agentDir, + pluginConfig: { computerUse: { ...params.computerUseConfig, autoInstall: true } }, + ownsIsolatedCodexHome: true, + ...(desktopGeneration ? { desktopGeneration } : {}), + forceCacheRefresh: true, + assertCurrent: () => assertCodexAppServerClientStartSelectionCurrent({ client }), }); } diff --git a/extensions/codex/src/app-server/models.test.ts b/extensions/codex/src/app-server/models.test.ts index 0de78d1b4cd1..6d100fa90795 100644 --- a/extensions/codex/src/app-server/models.test.ts +++ b/extensions/codex/src/app-server/models.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => { applyAuthProfile: vi.fn(async () => undefined), authProfileId: vi.fn((params?: { authProfileId?: string }) => params?.authProfileId), fallbackApiKeyCacheKey: vi.fn(() => undefined), + reconcileComputerUseArtifacts: vi.fn(async () => undefined), startOptions: vi.fn(async ({ startOptions }) => startOptions), }; const managedBinary = { @@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => { vi.mock("./auth-bridge.js", () => ({ applyCodexAppServerAuthProfile: mocks.authBridge.applyAuthProfile, bridgeCodexAppServerStartOptions: mocks.authBridge.startOptions, + reconcileCodexComputerUseStartArtifacts: mocks.authBridge.reconcileComputerUseArtifacts, resolveCodexAppServerFallbackApiKeyCacheKey: mocks.authBridge.fallbackApiKeyCacheKey, resolveCodexAppServerAuthProfileIdForAgent: mocks.authBridge.authProfileId, resolveCodexAppServerHomeDir: (agentDir: string) => `${agentDir}/codex-home`, diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 27544c805f58..e14b8ab5a1e7 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -13,8 +13,13 @@ import { createClientHarness } from "./test-support.js"; import { CODEX_APP_SERVER_VERSION, MIN_SUPPORTED_CODEX_APP_SERVER_VERSION } from "./version.js"; const mocks = vi.hoisted(() => ({ + CodexComputerUseCandidateArtifactsUnavailableError: class extends Error { + readonly code = "CODEX_COMPUTER_USE_CANDIDATE_ARTIFACTS_UNAVAILABLE"; + }, bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions), - reconcileCodexComputerUseStartArtifacts: vi.fn(async () => undefined), + reconcileCodexComputerUseStartArtifacts: vi.fn( + async (_params?: { startOptions: { command: string } }) => undefined, + ), applyCodexAppServerAuthProfile: vi.fn( async (_params?: { agentDir?: string; @@ -87,6 +92,15 @@ vi.mock("./desktop-generation.js", () => ({ })); vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ + AgentHarnessPreflightError: class extends Error { + readonly scope: string; + + constructor(message: string, options: { scope: string; cause?: unknown }) { + super(message, { cause: options.cause }); + this.name = "AgentHarnessPreflightError"; + this.scope = options.scope; + } + }, embeddedAgentLog: mocks.embeddedAgentLog, formatErrorMessage: (error: unknown) => String(error), OPENCLAW_VERSION: "test", @@ -491,6 +505,60 @@ describe("shared Codex app-server client", () => { await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true)); }); + it("falls back before starting a desktop candidate with incomplete Computer Use artifacts", async () => { + const pluginLocal = createClientHarness(); + const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(pluginLocal.client); + mocks.reconcileCodexComputerUseStartArtifacts + .mockRejectedValueOnce( + new mocks.CodexComputerUseCandidateArtifactsUnavailableError( + "desktop artifacts unavailable", + ), + ) + .mockResolvedValueOnce(undefined); + const startOptions = configureManagedDesktopFallback(); + + const acquire = getSharedCodexAppServerClient({ startOptions, timeoutMs: 1_000 }); + await sendInitializeResult(pluginLocal, "openclaw/0.147.0 (macOS; test)"); + const client = await acquire; + + expect(client).toBe(pluginLocal.client); + expect(startSpy).toHaveBeenCalledTimes(1); + expect(startSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: "/cache/openclaw/codex" }), + ); + expect(mocks.reconcileCodexComputerUseStartArtifacts).toHaveBeenCalledTimes(2); + expect(mocks.reconcileCodexComputerUseStartArtifacts.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + startOptions: expect.objectContaining({ + command: "/Applications/Codex.app/Contents/Resources/codex", + }), + }), + ); + expect(mocks.reconcileCodexComputerUseStartArtifacts.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + startOptions: expect.objectContaining({ command: "/cache/openclaw/codex" }), + }), + ); + }); + + it("classifies terminal incomplete Computer Use artifacts as harness preflight", async () => { + mocks.reconcileCodexComputerUseStartArtifacts.mockRejectedValueOnce( + new mocks.CodexComputerUseCandidateArtifactsUnavailableError("desktop artifacts unavailable"), + ); + + await expect( + getSharedCodexAppServerClient({ + startOptions: { + transport: "stdio", + command: "/Applications/Codex.app/Contents/Resources/codex", + commandSource: "config", + args: ["app-server"], + headers: {}, + }, + }), + ).rejects.toMatchObject({ name: "AgentHarnessPreflightError", scope: "harness" }); + }); + it("reuses the successful managed fallback after desktop initialize is unsupported", async () => { const desktop = createClientHarness(); const pluginLocal = createClientHarness(); @@ -2112,6 +2180,78 @@ describe("shared Codex app-server client", () => { await expect(acquire).rejects.toThrow("codex app-server initialize aborted"); }); + it("does not start a client after its sole waiter abandons artifact reconciliation", async () => { + const reconcileStarted = createDeferred(); + const releaseReconcile = createDeferred(); + const reconcileFinished = createDeferred(); + mocks.reconcileCodexComputerUseStartArtifacts.mockImplementationOnce( + async (value?: unknown) => { + const params = value as { assertCurrent?: () => void }; + reconcileStarted.resolve(); + await releaseReconcile.promise; + try { + params.assertCurrent?.(); + } finally { + reconcileFinished.resolve(); + } + }, + ); + const startSpy = vi.spyOn(CodexAppServerClient, "start"); + const abort = new AbortController(); + const acquire = getLeasedSharedCodexAppServerClient({ + config: {}, + agentDir: "/tmp/openclaw-agent", + startOptions: { + transport: "stdio", + homeScope: "agent", + command: "codex", + commandSource: "managed", + args: ["app-server"], + headers: {}, + }, + timeoutMs: 1_000, + abandonSignal: abort.signal, + }); + await reconcileStarted.promise; + + abort.abort(); + await expect(acquire).rejects.toThrow("codex app-server initialize aborted"); + releaseReconcile.resolve(); + await reconcileFinished.promise; + await Promise.resolve(); + expect(startSpy).not.toHaveBeenCalled(); + }); + + it("does not start a client abandoned after reconciliation's final currentness check", async () => { + const abort = new AbortController(); + mocks.reconcileCodexComputerUseStartArtifacts.mockImplementationOnce( + async (value?: unknown) => { + const params = value as { assertCurrent?: () => void }; + params.assertCurrent?.(); + abort.abort(); + }, + ); + const startSpy = vi.spyOn(CodexAppServerClient, "start"); + + await expect( + getLeasedSharedCodexAppServerClient({ + config: {}, + agentDir: "/tmp/openclaw-agent", + startOptions: { + transport: "stdio", + homeScope: "agent", + command: "codex", + commandSource: "managed", + args: ["app-server"], + headers: {}, + }, + timeoutMs: 1_000, + abandonSignal: abort.signal, + }), + ).rejects.toThrow("codex app-server initialize aborted"); + expect(startSpy).not.toHaveBeenCalled(); + }); + it("drains active desktop generation X while new acquisitions use Y", async () => { const generationX = { epoch: 1, fingerprint: "desktop-x" }; const generationY = { epoch: 2, fingerprint: "desktop-y" }; @@ -2215,14 +2355,16 @@ describe("shared Codex app-server client", () => { expect(clientX).toBe(desktopX.client); expect(clientY).toBe(desktopY.client); - expect(mocks.reconcileCodexComputerUseStartArtifacts).toHaveBeenCalledTimes(2); - expect(mocks.reconcileCodexComputerUseStartArtifacts).toHaveBeenLastCalledWith( - expect.objectContaining({ - startOptions: expect.objectContaining({ - command: "/Applications/Codex.app/Contents/Resources/codex", - }), - }), - ); + expect( + mocks.reconcileCodexComputerUseStartArtifacts.mock.calls.map( + ([params]) => params?.startOptions.command, + ), + ).toEqual([ + "/cache/openclaw/codex", + "/Applications/Codex.app/Contents/Resources/codex", + "/cache/openclaw/codex", + "/Applications/Codex.app/Contents/Resources/codex", + ]); expect(desktopX.process.stdin.destroyed).toBe(false); expect(releaseLeasedSharedCodexAppServerClient(clientX)).toBe(true); expect(desktopX.process.stdin.destroyed).toBe(true); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 090284f00394..471bc0e5ab38 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -4,7 +4,10 @@ */ import { createHash } from "node:crypto"; import path from "node:path"; -import type { AgentHarnessRuntimeArtifactBinding } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + AgentHarnessPreflightError, + type AgentHarnessRuntimeArtifactBinding, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { CodexAppServerStartupError } from "./attempt-timeouts.js"; @@ -55,7 +58,7 @@ type SharedCodexAppServerClientEntry = { pendingAcquires: number; closeWhenIdle: boolean; closeError?: Error; - runtimeArtifactStartupAbort?: AbortController; + startupAbort?: AbortController; onStartedClientCallbacks: Set<(client: CodexAppServerClient) => void>; }; @@ -165,11 +168,18 @@ export function readCodexAppServerClientProcessIdentity( }; } -/** Returns the lifecycle generation that owns a managed desktop client. */ +/** Returns the lifecycle fingerprint that owns a managed desktop client. */ export function readCodexAppServerClientDesktopGenerationFingerprint( client: CodexAppServerClient, ): string | undefined { - return getCodexAppServerClientStartMetadata().get(client)?.desktopGeneration?.fingerprint; + return readCodexAppServerClientDesktopGeneration(client)?.fingerprint; +} + +/** Returns the lifecycle generation that owns a managed desktop client. */ +export function readCodexAppServerClientDesktopGeneration( + client: CodexAppServerClient, +): CodexDesktopGeneration | undefined { + return getCodexAppServerClientStartMetadata().get(client)?.desktopGeneration; } /** Resolves non-secret spawn identity before startup; argv is represented only by its hash. */ @@ -732,9 +742,7 @@ async function acquireSharedCodexAppServerClient( retireSharedCodexAppServerClientIfCurrent(existingClient); entry = getOrCreateSharedClientEntry(state, key); } - if (runtimeArtifactMode) { - entry.runtimeArtifactStartupAbort ??= new AbortController(); - } + entry.startupAbort ??= new AbortController(); entry.closeWhenIdle = false; const releasePendingAcquire = retainPendingSharedClientAcquire(entry); const startedCallback = options?.onStartedClient; @@ -782,7 +790,8 @@ async function acquireSharedCodexAppServerClient( ...(options?.expectedRuntimeArtifact ? { expectedRuntimeArtifact: options.expectedRuntimeArtifact } : {}), - runtimeArtifactSignal: entry.runtimeArtifactStartupAbort?.signal, + runtimeArtifactSignal: entry.startupAbort.signal, + abandonSignal: entry.startupAbort.signal, config: options?.config, })); try { @@ -892,6 +901,7 @@ function createSharedCodexAppServerClientStartup(params: { runtimeArtifactMode?: "capture"; expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding; runtimeArtifactSignal?: AbortSignal; + abandonSignal?: AbortSignal; preparedAuth?: CodexAppServerResolvedPreparedAuth; authRequirement?: CodexAppServerAuthRequirement; config?: CodexAppServerClientOptions["config"]; @@ -912,6 +922,7 @@ function createSharedCodexAppServerClientStartup(params: { ? { expectedRuntimeArtifact: params.expectedRuntimeArtifact } : {}), runtimeArtifactSignal: params.runtimeArtifactSignal, + abandonSignal: params.abandonSignal, config: params.config, onStartedClient: (startedClient) => { const state = getSharedCodexAppServerClientState(); @@ -944,6 +955,7 @@ function createSharedCodexAppServerClientStartup(params: { }, ); // Callers observe pre-initialize failures through the phase promise first. + void initialized.promise.catch(() => undefined); void ready.catch(() => undefined); return { initialized: initialized.promise, ready }; } @@ -1028,15 +1040,36 @@ async function startInitializedCodexAppServerClient(params: { params.abandonSignal, ) : undefined); - if (index > 0) { + const assertDesktopGenerationCurrent = () => { + if (params.abandonSignal?.aborted) { + throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted"); + } + if (desktopGeneration && !isCodexDesktopGenerationCurrent(desktopGeneration)) { + throw new CodexAppServerStartSelectionChangedError(); + } + }; + try { await reconcileCodexComputerUseStartArtifacts({ startOptions, agentDir: params.agentDir, pluginConfig: params.pluginConfig, + ...(desktopGeneration ? { desktopGeneration } : {}), + assertCurrent: assertDesktopGenerationCurrent, ownsIsolatedCodexHome: params.requestedStartOptions.homeScope !== "user" && !params.requestedStartOptions.env?.CODEX_HOME?.trim(), }); + } catch (error) { + if (isCodexComputerUseCandidateArtifactsUnavailableError(error)) { + if (index + 1 < startOptionsCandidates.length) { + continue; + } + throw new AgentHarnessPreflightError( + "Codex Computer Use artifacts are unavailable from the installed desktop apps.", + { cause: error, scope: "harness" }, + ); + } + throw error; } const runtimeArtifactModule = params.runtimeArtifactMode ? await import("./runtime-artifact.js") @@ -1066,9 +1099,7 @@ async function startInitializedCodexAppServerClient(params: { } throw new Error("Codex app-server runtime artifact does not match verified inference"); } - if (desktopGeneration && !isCodexDesktopGenerationCurrent(desktopGeneration)) { - throw new CodexAppServerStartSelectionChangedError(); - } + assertDesktopGenerationCurrent(); const client = CodexAppServerClient.start(startOptions); const nativeCommandAtStart = startOptions.commandSource === "resolved-managed" @@ -1100,9 +1131,11 @@ async function startInitializedCodexAppServerClient(params: { throw error; } - if (desktopGeneration && !isCodexDesktopGenerationCurrent(desktopGeneration)) { + try { + assertDesktopGenerationCurrent(); + } catch (error) { client.close(); - throw new CodexAppServerStartSelectionChangedError(); + throw error; } params.onInitializedClient?.(); @@ -1141,9 +1174,7 @@ async function startInitializedCodexAppServerClient(params: { }); try { - if (desktopGeneration && !isCodexDesktopGenerationCurrent(desktopGeneration)) { - throw new CodexAppServerStartSelectionChangedError(); - } + assertDesktopGenerationCurrent(); await withCodexAppServerAcquireDeadline( resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt), applyCodexAppServerAuthProfile({ @@ -1161,9 +1192,7 @@ async function startInitializedCodexAppServerClient(params: { if (runtimeArtifactModule && runtimeArtifact) { runtimeArtifactModule.bindCodexAppServerRuntimeArtifact(client, runtimeArtifact); } - if (desktopGeneration && !isCodexDesktopGenerationCurrent(desktopGeneration)) { - throw new CodexAppServerStartSelectionChangedError(); - } + assertDesktopGenerationCurrent(); const fenceKey = resolveCodexNativeConfigFenceKey({ client }); if (fenceKey) { client.setThreadSessionRequestGuard(async (options) => { @@ -1186,6 +1215,15 @@ async function startInitializedCodexAppServerClient(params: { throw new Error("Managed Codex app-server fallback candidates were exhausted."); } +function isCodexComputerUseCandidateArtifactsUnavailableError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "CODEX_COMPUTER_USE_CANDIDATE_ARTIFACTS_UNAVAILABLE" + ); +} + function resolveManagedFallbackStartOptions( startOptions: CodexAppServerStartOptions, ): CodexAppServerStartOptions[] { @@ -1523,9 +1561,7 @@ function retirePendingSharedClientEntryIfUnclaimed( if (entry.activeLeases > 0 || entry.pendingAcquires > 0) { return; } - entry.runtimeArtifactStartupAbort?.abort( - new Error("Codex runtime artifact startup was abandoned"), - ); + entry.startupAbort?.abort(new Error("Codex app-server startup was abandoned")); entry.closeWhenIdle = true; const state = getSharedCodexAppServerClientState(); if (state.clients.get(key) === entry) {