mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(codex): bind desktop artifact reconciliation to generation
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user