fix(codex): bind desktop artifact reconciliation to generation

This commit is contained in:
joshavant
2026-08-22 01:36:46 -05:00
parent 7750f7ee6d
commit 8140d93e3c
13 changed files with 1248 additions and 136 deletions
@@ -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<typeof resolveMacOSDesktopCodexAppPathCandidates>[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<MockCacheResult>
>(async () => ({
status: "independent",
changed: false,
message: "independent",
removedStaleVersions: [],
warnings: [],
})),
ensureCodexManagedBundledMarketplace: vi.fn<(_params?: unknown) => Promise<string | undefined>>(
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<MockDesktopCandidate | undefined>
>(async (params) => params.candidates?.[0]),
resolveCodexComputerUseServiceAppSourcePath: vi.fn<
(params: { sourceAppCandidates?: readonly string[] }) => Promise<string | undefined>
>(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<void>();
const releaseFirstMarketplace = createDeferred<void>();
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");
+208 -35
View File
@@ -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<void> }
>();
type AuthProfileOrderConfig = Parameters<typeof resolveAuthProfileOrder>[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<CodexAppServerStartOptions> {
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<void> {
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<string | undefined> {
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<MacOSDesktopCodexAppPathCandidate | undefined> {
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";
}
}
@@ -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");
@@ -41,6 +41,8 @@ export async function ensureCodexComputerUseSharedPluginCache(params: {
bundledMarketplacePath?: string;
bundledMarketplacePathCandidates?: readonly string[];
ownershipRoot?: string;
assertCurrent?: () => void;
forceRefresh?: boolean;
}): Promise<CodexComputerUsePluginCacheRepairResult> {
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<boolean> {
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) {
@@ -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<void>();
const releaseFirstPublish = createDeferred<void>();
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<void>();
const releaseFirstStaging = createDeferred<void>();
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);
@@ -14,7 +14,10 @@ import {
} from "./desktop-app-paths.js";
const MARKETPLACE_NAME = "openai-bundled";
const activeInstalls = new Map<string, Promise<string | undefined>>();
const activeInstalls = new Map<
string,
{ sourcePath: string; promise: Promise<string | undefined> }
>();
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<string | undefined> {
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<typeof publishManagedWrapper>[0],
): Promise<string> {
if (await wrapperMatches(params.physicalTargetPath, params.source.bundledMarketplacePath)) {
return params.targetPath;
}
return await publishManagedWrapper(params);
}
async function publishManagedWrapper(params: {
parent: Awaited<ReturnType<typeof prepareOwnedServiceParent>>;
physicalTargetPath: string;
targetPath: string;
source: MacOSDesktopCodexAppPathCandidate;
candidates: readonly MacOSDesktopCodexAppPathCandidate[];
ownershipCandidates: readonly MacOSDesktopCodexAppPathCandidate[];
assertCurrent?: () => void;
}): Promise<string> {
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<MacOSDesktopCodexAppPathCandidate | undefined> {
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)) {
@@ -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({
@@ -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<string | undefined> {
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<CodexComputerUseServiceStatus> {
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<CodexComputerUseServiceStatus> {
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) {
@@ -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<typeof import("./computer-use-marketplace.js")>()),
ensureCodexManagedBundledMarketplace:
managedProvisioningMocks.ensureCodexManagedBundledMarketplace,
resolveCodexManagedBundledMarketplaceSource:
managedProvisioningMocks.resolveCodexManagedBundledMarketplaceSource,
}));
vi.mock("./computer-use-service.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./computer-use-service.js")>()),
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 });
+23 -16
View File
@@ -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 }),
});
}
@@ -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`,
@@ -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<void>();
const releaseReconcile = createDeferred<void>();
const reconcileFinished = createDeferred<void>();
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);
@@ -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) {