fix(codex): stop Computer Use readiness stalls across fallbacks (#113393)

* fix(codex): bound computer use readiness preflight

* chore(codex): keep service status internal
This commit is contained in:
Jason (Json)
2026-07-24 14:27:53 -06:00
committed by GitHub
parent 663c4fba10
commit ee606def49
16 changed files with 576 additions and 31 deletions

View File

@@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account
6b674e7aa4006240227c16eb5c74360f1d67639e8f5580ad72499d6ed28283c4 module/account-resolution
e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
aee7101637888134b817a1356f0019a1ded7cedc1c6457d208e7d368272db976 module/agent-harness-runtime
e62f3ed1976586cf85ed2a3395b2e255968d57389ec97999e06b2b3a2b978a33 module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
8b406b7a50b0b4f088be869ea6a899c0d33229a5c73d3f82bda76ce0be0941b0 module/agent-runtime
dd9282f1eeadf44db2887599b52d80db7f5fb99c6d9eac720dbf1b77065f2145 module/allow-from

View File

@@ -107,13 +107,19 @@ Computer Use in and lets OpenClaw install or re-enable it before the turn:
With this config, OpenClaw checks Codex app-server before each Codex-mode
turn. If Computer Use is missing but Codex app-server has already discovered
an installable marketplace, OpenClaw asks Codex app-server to install or
re-enable the plugin and reload MCP servers. On macOS, when no matching
re-enable the plugin and reload MCP servers. Before starting an isolated
Codex app-server on macOS, auto-install also copies the official signed
Computer Use service app from the selected desktop app bundle into that
Codex home's `computer-use` directory when the native client is missing.
On macOS, when no matching
marketplace is registered and a standard desktop app bundle exists, OpenClaw
also tries to register the bundled Codex marketplace from
`/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled`, with
`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` retained
as a fallback for legacy standalone installs. If setup still cannot make the
MCP server available, the turn fails before the thread starts.
Strict readiness failures are harness preflight failures, so model fallback
does not repeat the same local readiness sequence for every model candidate.
After changing Computer Use config, use `/new` or `/reset` in the affected
chat before testing if an existing Codex thread has already started.
@@ -255,7 +261,7 @@ remote install is unsupported, run install with a local source or path:
| Field | Default | Meaning |
| ------------------------------- | -------------- | ------------------------------------------------------------------------------ |
| `enabled` | inferred | Require Computer Use. Defaults to true when another Computer Use field is set. |
| `autoInstall` | false | Install or re-enable from already discovered marketplaces at turn start. |
| `autoInstall` | false | Provision the native client and install or re-enable the plugin at turn start. |
| `marketplaceDiscoveryTimeoutMs` | 60000 | How long install waits for Codex app-server marketplace discovery. |
| `liveTestTimeoutMs` | 60000 | Timeout for the temporary readiness thread and its cleanup requests. |
| `toolCallTimeoutMs` | 60000 | Timeout for the Computer Use `list_apps` readiness tool call. |

View File

@@ -3,9 +3,10 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
CodexBundleMcpThreadConfig,
EmbeddedRunAttemptParams,
import {
AgentHarnessPreflightError,
type CodexBundleMcpThreadConfig,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
@@ -674,7 +675,9 @@ describe("startCodexAttemptThread", () => {
});
await expect(run).rejects.toThrow("stop after option capture");
expect(clientFactory).toHaveBeenCalledWith(expect.objectContaining({ preparedAuth }));
expect(clientFactory).toHaveBeenCalledWith(
expect.objectContaining({ preparedAuth, pluginConfig }),
);
expect(clientFactory.mock.calls[0]?.[0]?.preparedAuth).toBe(preparedAuth);
expect(clientFactory).not.toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: expect.anything() }),
@@ -818,9 +821,10 @@ describe("startCodexAttemptThread", () => {
await vi.advanceTimersByTimeAsync(1_000);
const error = await runError;
expect(error).toBeInstanceOf(Error);
expect(isCodexAppServerRequestTimeoutError(error)).toBe(true);
expect((error as Error).message).toBe("plugin/list timed out");
expect(error).toBeInstanceOf(AgentHarnessPreflightError);
const cause = (error as Error).cause;
expect(isCodexAppServerRequestTimeoutError(cause)).toBe(true);
expect((cause as Error).message).toBe("plugin/list timed out");
expect(harness.process.stdin.destroyed).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@
* leasing, plugin thread config, sandbox environment, and thread lifecycle binding.
*/
import {
AgentHarnessPreflightError,
embeddedAgentLog,
formatErrorMessage,
type AgentHarnessRuntimeArtifactBinding,
@@ -226,6 +227,7 @@ export async function startCodexAttemptThread(params: {
const attemptParams = params.buildAttemptParams();
startupClient = await params.attemptClientFactory({
startOptions: params.appServer.start,
pluginConfig: params.pluginConfig,
...(params.startupPreparedAuth
? { preparedAuth: params.startupPreparedAuth }
: { authProfileId: params.startupAuthProfileId }),
@@ -313,14 +315,24 @@ export async function startCodexAttemptThread(params: {
config: params.config,
});
const turnRouter = getCodexAppServerTurnRouter(activeStartupClient);
await ensureCodexComputerUse({
client: activeStartupClient,
pluginConfig: params.pluginConfig,
config: params.config,
agentDir: params.agentDir,
timeoutMs: params.appServer.requestTimeoutMs,
signal: startupAbandonController.signal,
});
try {
await ensureCodexComputerUse({
client: activeStartupClient,
pluginConfig: params.pluginConfig,
config: params.config,
agentDir: params.agentDir,
timeoutMs: params.appServer.requestTimeoutMs,
signal: startupAbandonController.signal,
});
} catch (error) {
if (startupAbandonController.signal.aborted) {
throw error;
}
throw new AgentHarnessPreflightError(
`Codex Computer Use readiness failed: ${formatErrorMessage(error)}`,
{ cause: error },
);
}
const startupRuntimeIdentity = activeStartupClient.getRuntimeIdentity();
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
appServer: params.appServer,

View File

@@ -30,6 +30,13 @@ const oauthMocks = vi.hoisted(() => ({
refreshOpenAICodexToken: vi.fn(),
}));
const computerUseServiceMocks = vi.hoisted(() => ({
ensureCodexComputerUseServiceApp: vi.fn(async () => ({
status: "already_installed" as const,
changed: false,
})),
}));
const providerRuntimeMocks = vi.hoisted(() => ({
formatProviderAuthProfileApiKeyWithPlugin: vi.fn(),
refreshProviderOAuthCredentialWithPlugin: vi.fn(
@@ -117,12 +124,17 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
};
});
vi.mock("./computer-use-service.js", () => ({
ensureCodexComputerUseServiceApp: computerUseServiceMocks.ensureCodexComputerUseServiceApp,
}));
afterEach(() => {
vi.unstubAllEnvs();
clearRuntimeAuthProfileStoreSnapshots();
oauthMocks.refreshOpenAICodexToken.mockReset();
providerRuntimeMocks.formatProviderAuthProfileApiKeyWithPlugin.mockReset();
providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin.mockClear();
computerUseServiceMocks.ensureCodexComputerUseServiceApp.mockClear();
});
function createStartOptions(
@@ -241,6 +253,50 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("provisions the native Computer Use client before auto-install startup", async () => {
await withTempDir("openclaw-codex-computer-use-service-", async (agentDir) => {
const startOptions = createStartOptions();
const codexHome = resolveCodexAppServerHomeDir(agentDir);
await bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
pluginConfig: { computerUse: { enabled: true, autoInstall: true } },
});
expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).toHaveBeenCalledWith({
codexHome,
appServerCommand: startOptions.command,
});
});
});
it("does not provision the native client without auto-install authorization", async () => {
await withTempDir("openclaw-codex-computer-use-service-", async (agentDir) => {
await bridgeCodexAppServerStartOptions({
startOptions: createStartOptions(),
agentDir,
pluginConfig: { computerUse: { enabled: true, autoInstall: false } },
});
expect(computerUseServiceMocks.ensureCodexComputerUseServiceApp).not.toHaveBeenCalled();
});
});
it("classifies native client provisioning failures as harness preflight", async () => {
computerUseServiceMocks.ensureCodexComputerUseServiceApp.mockRejectedValueOnce(
new Error("copy failed"),
);
await expect(
bridgeCodexAppServerStartOptions({
startOptions: createStartOptions(),
agentDir: "/tmp/openclaw-codex-computer-use-failed",
pluginConfig: { computerUse: { enabled: true, autoInstall: true } },
}),
).rejects.toMatchObject({ name: "AgentHarnessPreflightError" });
});
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");

View File

@@ -6,6 +6,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { AgentHarnessPreflightError } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
ensureAuthProfileStore,
findPersistedAuthProfileCredential,
@@ -24,6 +25,7 @@ import { hasUsableOAuthCredential } from "openclaw/plugin-sdk/provider-auth";
import { resolveCodexAppServerHomeDir, withEphemeralCodexAuthStore } from "./auth-start-options.js";
import type { CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUseSharedPluginCache } from "./computer-use-cache.js";
import { ensureCodexComputerUseServiceApp } from "./computer-use-service.js";
import {
resolveCodexAppServerUserHomeDir,
resolveCodexComputerUseConfig,
@@ -437,10 +439,23 @@ async function withCodexHomeEnvironment(
? startOptions.env[HOME_ENV_VAR]
: undefined;
await fs.mkdir(codexHome, { recursive: true });
const computerUseConfig = resolveCodexComputerUseConfig({ pluginConfig });
await ensureCodexComputerUseSharedPluginCache({
codexHome,
config: resolveCodexComputerUseConfig({ pluginConfig }),
config: computerUseConfig,
});
if (computerUseConfig.enabled && computerUseConfig.autoInstall) {
try {
await ensureCodexComputerUseServiceApp({
codexHome,
appServerCommand: startOptions.command,
});
} catch (error) {
throw new AgentHarnessPreflightError("Codex Computer Use client provisioning failed.", {
cause: error,
});
}
}
if (nativeHome) {
await fs.mkdir(nativeHome, { recursive: true });
}

View File

@@ -0,0 +1,124 @@
// Codex tests cover native Computer Use service provisioning.
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureCodexComputerUseServiceApp } from "./computer-use-service.js";
import { resolveMacOSDesktopCodexComputerUseServiceAppCandidates } from "./desktop-app-paths.js";
import { useAutoCleanupTempDirTracker } from "./test-support.js";
const CLIENT_RELATIVE_PATH = path.join(
"Contents",
"SharedSupport",
"SkyComputerUseClient.app",
"Contents",
"MacOS",
"SkyComputerUseClient",
);
describe("Codex Computer Use native service", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it("installs the official client beneath the isolated Codex home", async () => {
const root = tempDirs.make("openclaw-computer-use-service-");
const sourcePath = path.join(root, "source", "Codex Computer Use.app");
const codexHome = path.join(root, "agent", "codex-home");
await writeExecutableClient(sourcePath);
const result = await ensureCodexComputerUseServiceApp({
codexHome,
platform: "darwin",
sourceAppCandidates: [sourcePath],
copyServiceApp: async (source, target) => await fs.cp(source, target, { recursive: true }),
});
expect(result).toMatchObject({ status: "installed", changed: true, sourcePath });
await expect(
fs.access(
path.join(codexHome, "computer-use", "Codex Computer Use.app", CLIENT_RELATIVE_PATH),
),
).resolves.toBeUndefined();
});
it("reuses a complete home-owned client without consulting desktop sources", async () => {
const root = tempDirs.make("openclaw-computer-use-service-");
const codexHome = path.join(root, "codex-home");
await writeExecutableClient(path.join(codexHome, "computer-use", "Codex Computer Use.app"));
const copyServiceApp = vi.fn();
const result = await ensureCodexComputerUseServiceApp({
codexHome,
platform: "darwin",
sourceAppCandidates: [],
copyServiceApp,
});
expect(result).toMatchObject({ status: "already_installed", changed: false });
expect(copyServiceApp).not.toHaveBeenCalled();
});
it("reports a missing source without creating a partial target", async () => {
const root = tempDirs.make("openclaw-computer-use-service-");
const codexHome = path.join(root, "codex-home");
const result = await ensureCodexComputerUseServiceApp({
codexHome,
platform: "darwin",
sourceAppCandidates: [path.join(root, "missing.app")],
});
expect(result).toMatchObject({ status: "source_missing", changed: false });
await expect(fs.access(path.join(codexHome, "computer-use"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("replaces an incomplete home-owned service app", async () => {
const root = tempDirs.make("openclaw-computer-use-service-");
const sourcePath = path.join(root, "source", "Codex Computer Use.app");
const codexHome = path.join(root, "codex-home");
const targetPath = path.join(codexHome, "computer-use", "Codex Computer Use.app");
await writeExecutableClient(sourcePath);
await fs.mkdir(targetPath, { recursive: true });
await fs.writeFile(path.join(targetPath, "partial"), "incomplete");
const result = await ensureCodexComputerUseServiceApp({
codexHome,
platform: "darwin",
sourceAppCandidates: [sourcePath],
copyServiceApp: async (source, target) => await fs.cp(source, target, { recursive: true }),
});
expect(result).toMatchObject({ status: "installed", changed: true });
await expect(fs.access(path.join(targetPath, CLIENT_RELATIVE_PATH))).resolves.toBeUndefined();
await expect(fs.access(path.join(targetPath, "partial"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not provision the macOS service on other platforms", async () => {
const result = await ensureCodexComputerUseServiceApp({
codexHome: "/tmp/codex-home",
platform: "linux",
});
expect(result).toEqual({ status: "unsupported", changed: false });
});
it("prefers service assets owned by the selected desktop app-server", () => {
expect(
resolveMacOSDesktopCodexComputerUseServiceAppCandidates(
"darwin",
"/Applications/Codex.app/Contents/Resources/codex",
)[0],
).toBe(
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use/Codex Computer Use.app",
);
});
});
async function writeExecutableClient(appPath: string): Promise<void> {
const clientPath = path.join(appPath, CLIENT_RELATIVE_PATH);
await fs.mkdir(path.dirname(clientPath), { recursive: true });
await fs.writeFile(clientPath, "client");
await fs.chmod(clientPath, 0o755);
}

View File

@@ -0,0 +1,169 @@
/** Native Computer Use service provisioning for isolated Codex homes. */
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { runExec } from "openclaw/plugin-sdk/process-runtime";
import { resolveMacOSDesktopCodexComputerUseServiceAppCandidates } from "./desktop-app-paths.js";
const SERVICE_APP_NAME = "Codex Computer Use.app";
const CLIENT_RELATIVE_PATH = path.join(
"Contents",
"SharedSupport",
"SkyComputerUseClient.app",
"Contents",
"MacOS",
"SkyComputerUseClient",
);
const COPY_TIMEOUT_MS = 120_000;
const activeInstalls = new Map<string, Promise<CodexComputerUseServiceStatus>>();
type CodexComputerUseServiceStatus = {
status: "installed" | "already_installed" | "source_missing" | "unsupported";
changed: boolean;
targetPath?: string;
sourcePath?: string;
};
type CopyServiceApp = (sourcePath: string, targetPath: string) => Promise<void>;
/** Ensures the official native client exists beneath the CODEX_HOME used by its launcher. */
export async function ensureCodexComputerUseServiceApp(params: {
codexHome: string;
platform?: NodeJS.Platform;
appServerCommand?: string;
sourceAppCandidates?: readonly string[];
copyServiceApp?: CopyServiceApp;
}): Promise<CodexComputerUseServiceStatus> {
const platform = params.platform ?? process.platform;
if (platform !== "darwin") {
return { status: "unsupported", changed: false };
}
const targetPath = path.join(path.resolve(params.codexHome), "computer-use", SERVICE_APP_NAME);
const active = activeInstalls.get(targetPath);
if (active) {
return await active;
}
const install = ensureCodexComputerUseServiceAppOnce({ ...params, targetPath, platform });
activeInstalls.set(targetPath, install);
try {
return await install;
} finally {
if (activeInstalls.get(targetPath) === install) {
activeInstalls.delete(targetPath);
}
}
}
async function ensureCodexComputerUseServiceAppOnce(params: {
codexHome: string;
targetPath: string;
platform: NodeJS.Platform;
appServerCommand?: string;
sourceAppCandidates?: readonly string[];
copyServiceApp?: CopyServiceApp;
}): Promise<CodexComputerUseServiceStatus> {
if (await hasExecutableClient(params.targetPath)) {
return { status: "already_installed", changed: false, targetPath: params.targetPath };
}
const candidates =
params.sourceAppCandidates ??
resolveMacOSDesktopCodexComputerUseServiceAppCandidates(
params.platform,
params.appServerCommand,
);
const sourcePath = await findUsableServiceApp(candidates);
if (!sourcePath) {
return { status: "source_missing", changed: false, targetPath: params.targetPath };
}
const targetParent = path.dirname(params.targetPath);
await fs.mkdir(targetParent, { recursive: true });
const stagingRoot = await fs.mkdtemp(path.join(targetParent, ".service-app.staging-"));
const stagedPath = path.join(stagingRoot, SERVICE_APP_NAME);
const backupPath = path.join(targetParent, `.service-app.backup-${process.pid}-${Date.now()}`);
let backupCreated = false;
try {
await (params.copyServiceApp ?? copyServiceAppWithDitto)(sourcePath, stagedPath);
if (!(await hasExecutableClient(stagedPath))) {
throw new Error(`Copied Computer Use service app is incomplete at ${stagedPath}.`);
}
if (await pathExists(params.targetPath)) {
await fs.rename(params.targetPath, backupPath);
backupCreated = true;
if (await hasExecutableClient(backupPath)) {
// A separate runtime can win after the initial target check. Restore
// its complete signed app rather than replacing it from our staging copy.
await fs.rename(backupPath, params.targetPath);
backupCreated = false;
return {
status: "already_installed",
changed: false,
targetPath: params.targetPath,
sourcePath,
};
}
}
try {
await fs.rename(stagedPath, params.targetPath);
} catch (error) {
// Another runtime can finish the same isolated-home install first.
// Preserve that complete winner instead of replacing a live signed app.
if (!(await hasExecutableClient(params.targetPath))) {
if (backupCreated) {
await fs.rename(backupPath, params.targetPath);
backupCreated = false;
}
throw error;
}
if (backupCreated) {
await fs.rm(backupPath, { recursive: true, force: true });
backupCreated = false;
}
return {
status: "already_installed",
changed: false,
targetPath: params.targetPath,
sourcePath,
};
}
if (backupCreated) {
await fs.rm(backupPath, { recursive: true, force: true });
backupCreated = false;
}
return { status: "installed", changed: true, targetPath: params.targetPath, sourcePath };
} finally {
await fs.rm(stagingRoot, { recursive: true, force: true });
}
}
async function pathExists(filePath: string): Promise<boolean> {
return await fs.lstat(filePath).then(
() => true,
() => false,
);
}
async function findUsableServiceApp(candidates: readonly string[]): Promise<string | undefined> {
for (const candidate of candidates) {
if (await hasExecutableClient(candidate)) {
return candidate;
}
}
return undefined;
}
async function hasExecutableClient(appPath: string): Promise<boolean> {
try {
await fs.access(path.join(appPath, CLIENT_RELATIVE_PATH), fsConstants.X_OK);
return true;
} catch {
return false;
}
}
async function copyServiceAppWithDitto(sourcePath: string, targetPath: string): Promise<void> {
await runExec("/usr/bin/ditto", ["--noqtn", sourcePath, targetPath], {
logOutput: false,
timeoutMs: COPY_TIMEOUT_MS,
});
}

View File

@@ -370,6 +370,48 @@ describe("Codex Computer Use setup", () => {
).toHaveLength(2);
});
it("fails fast when the named MCP server exposes no tools", async () => {
const request = createComputerUseRequest({ installed: true, mcpToolsAvailable: false });
await expectSetupErrorStatus(
ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
strictReadiness: true,
marketplaceName: "desktop-tools",
},
},
request,
}),
{
ready: false,
reason: "mcp_missing",
mcpServerAvailable: false,
tools: [],
message: "Computer Use is installed, but the computer-use MCP server exposes no tools.",
},
);
expectRequestMethodNotCalled(request, "thread/start");
expectRequestMethodNotCalled(request, "mcpServer/tool/call");
});
it("reloads empty MCP exposure once during install before failing closed", async () => {
const request = createComputerUseRequest({ installed: true, mcpToolsAvailable: false });
await expectSetupErrorStatus(
installCodexComputerUse({
pluginConfig: { computerUse: { marketplaceName: "desktop-tools" } },
request,
}),
{ ready: false, reason: "mcp_missing", mcpServerAvailable: false },
);
expect(
requestCalls(request).filter(([method]) => method === "config/mcpServer/reload"),
).toHaveLength(1);
expectRequestMethodNotCalled(request, "thread/start");
});
it("does not repair stale Computer Use MCP children unless autoRepair is enabled", async () => {
const request = createComputerUseRequest({ installed: true, liveTestFailures: 2 });
const repairComputerUseMcpChildren = vi.fn(async () => ({
@@ -1008,6 +1050,7 @@ function createComputerUseRequest(params: {
enabled?: boolean;
marketplaceAvailableAfterListCalls?: number;
liveTestFailures?: number;
mcpToolsAvailable?: boolean;
}): CodexComputerUseRequest {
let installed = params.installed;
let enabled = params.enabled ?? installed;
@@ -1073,12 +1116,15 @@ function createComputerUseRequest(params: {
? [
{
name: "computer-use",
tools: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
tools:
params.mcpToolsAvailable === false
? {}
: {
list_apps: {
name: "list_apps",
inputSchema: { type: "object" },
},
},
resources: [],
resourceTemplates: [],
authStatus: "unsupported",

View File

@@ -317,6 +317,7 @@ async function inspectCodexComputerUse(
}
client = await getLeasedSharedCodexAppServerClient({
startOptions: runtime.start,
pluginConfig: params.pluginConfig,
timeoutMs: params.timeoutMs ?? runtime.requestTimeoutMs,
config: params.config,
agentDir: params.agentDir,
@@ -480,9 +481,11 @@ async function readComputerUseTools(params: {
repairComputerUseMcpChildren?: () => Promise<CodexComputerUseRepairStatus>;
}): Promise<CodexComputerUseStatus> {
let server = await readMcpServerStatus(params.request, params.config.mcpServerName);
if (!server && params.installPlugin) {
let tools = Object.keys(server?.tools ?? {}).toSorted();
if ((!server || tools.length === 0) && params.installPlugin) {
await reloadMcpServers(params.request);
server = await readMcpServerStatus(params.request, params.config.mcpServerName);
tools = Object.keys(server?.tools ?? {}).toSorted();
}
if (!server) {
return statusFromPlugin({
@@ -493,11 +496,20 @@ async function readComputerUseTools(params: {
message: `Computer Use is installed, but the ${params.config.mcpServerName} MCP server is not available.`,
});
}
if (tools.length === 0) {
return statusFromPlugin({
config: params.config,
plugin: params.plugin,
tools,
reason: "mcp_missing",
message: `Computer Use is installed, but the ${params.config.mcpServerName} MCP server exposes no tools.`,
});
}
const status = statusFromPlugin({
config: params.config,
plugin: params.plugin,
tools: Object.keys(server.tools).toSorted(),
tools,
reason: "ready",
message: "Computer Use is ready.",
});

View File

@@ -1,11 +1,13 @@
/** Shared path candidates for Codex's macOS desktop app bundle. */
import { existsSync } from "node:fs";
import path from "node:path";
type MacOSDesktopCodexAppPathCandidate = {
appName: "ChatGPT.app" | "Codex.app";
appBundlePath: string;
appServerCommandPath: string;
bundledMarketplacePath: string;
computerUseServiceAppPaths: readonly string[];
};
const MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES: readonly MacOSDesktopCodexAppPathCandidate[] = [
@@ -14,12 +16,20 @@ const MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES: readonly MacOSDesktopCodexAppPath
appBundlePath: "/Applications/ChatGPT.app",
appServerCommandPath: "/Applications/ChatGPT.app/Contents/Resources/codex",
bundledMarketplacePath: "/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled",
computerUseServiceAppPaths: [
"/Applications/ChatGPT.app/Contents/Resources/cua_node/lib/node_modules/@oai/sky/Codex Computer Use.app",
"/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use/Codex Computer Use.app",
],
},
{
appName: "Codex.app",
appBundlePath: "/Applications/Codex.app",
appServerCommandPath: "/Applications/Codex.app/Contents/Resources/codex",
bundledMarketplacePath: "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled",
computerUseServiceAppPaths: [
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use/Codex Computer Use.app",
"/Applications/Codex.app/Contents/Resources/cua_node/lib/node_modules/@oai/sky/Codex Computer Use.app",
],
},
] as const;
@@ -31,6 +41,32 @@ export function resolveMacOSDesktopCodexBundledMarketplaceCandidates(
: [];
}
export function resolveMacOSDesktopCodexComputerUseServiceAppCandidates(
platform: NodeJS.Platform = process.platform,
appServerCommand?: string,
): string[] {
if (platform !== "darwin") {
return [];
}
const matchingCandidate = appServerCommand
? MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES.find(
(candidate) =>
path.resolve(candidate.appServerCommandPath) === path.resolve(appServerCommand),
)
: undefined;
const orderedCandidates = matchingCandidate
? [
matchingCandidate,
...MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES.filter(
(candidate) => candidate !== matchingCandidate,
),
]
: MACOS_DESKTOP_CODEX_APP_PATH_CANDIDATES;
return [
...new Set(orderedCandidates.flatMap((candidate) => candidate.computerUseServiceAppPaths)),
];
}
export function resolveFirstExistingMacOSDesktopCodexBundledMarketplacePath(
params: {
platform?: NodeJS.Platform;

View File

@@ -172,7 +172,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: canonical unknown-value to Error coercion.
// +6: canonical session delivery normalization, access, and projection helpers.
// +5: focused media-local-roots helpers and typed hook media contracts.
4716,
// +1: model-independent agent-harness preflight failure contract.
4717,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -27,3 +27,16 @@ export class AgentHarnessSessionSupersededError extends Error {
this.name = "AgentHarnessSessionSupersededError";
}
}
/** A model-independent harness preflight failed before an attempt could start. */
export class AgentHarnessPreflightError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "AgentHarnessPreflightError";
}
}
/** Returns whether fallback would only repeat the same harness preflight failure. */
export function isAgentHarnessPreflightError(err: unknown): err is AgentHarnessPreflightError {
return err instanceof AgentHarnessPreflightError;
}

View File

@@ -25,7 +25,11 @@ import { abortable } from "./embedded-agent-runner/run/abortable.js";
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js";
import { FailoverError } from "./failover-error.js";
import { resetFallbackSkipCacheForTest } from "./fallback-skip-cache.test-support.js";
import { AgentHarnessSessionSupersededError, MissingAgentHarnessError } from "./harness/errors.js";
import {
AgentHarnessPreflightError,
AgentHarnessSessionSupersededError,
MissingAgentHarnessError,
} from "./harness/errors.js";
import { clearAgentHarnesses, registerAgentHarness } from "./harness/registry.js";
import type { AgentHarness } from "./harness/types.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
@@ -1649,6 +1653,36 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("does not repeat model-independent harness preflight on fallback models", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-4.1-mini"],
},
},
},
});
const preflightError = new AgentHarnessPreflightError(
"Computer Use live test failed after 2 attempts: thread/start timed out",
);
const run = vi.fn(async () => {
await Promise.resolve();
throw preflightError;
});
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.4",
run,
}),
).rejects.toBe(preflightError);
expect(run).toHaveBeenCalledTimes(1);
});
it("aborts fallback when a provider prompt error carries cleanup session takeover", async () => {
const cfg = makeCfg({
agents: {

View File

@@ -55,7 +55,11 @@ import {
isFallbackCandidateSkipped,
markFallbackCandidateSkipped,
} from "./fallback-skip-cache.js";
import { MissingAgentHarnessError, isMissingAgentHarnessError } from "./harness/errors.js";
import {
MissingAgentHarnessError,
isAgentHarnessPreflightError,
isMissingAgentHarnessError,
} from "./harness/errors.js";
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
import { getRegisteredAgentHarness } from "./harness/registry.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
@@ -414,6 +418,11 @@ async function runFallbackCandidate<T>(params: {
if (isCommandLaneTaskTimeoutError(err)) {
throw err;
}
// Harness preflight is model-independent. Preserve its typed ownership
// before timeout-shaped messages can be normalized as provider failures.
if (isAgentHarnessPreflightError(err)) {
throw err;
}
const fallbackError = resolveModelFallbackError(err, {
provider: params.provider,
model: params.model,
@@ -1891,6 +1900,11 @@ async function runWithModelFallbackInternal<T>(
if (isMissingAgentHarnessError(err)) {
throw err;
}
// Harness preflight depends on the selected runtime and its local state,
// not the model candidate. Retrying it would only amplify the same stall.
if (isAgentHarnessPreflightError(err)) {
throw err;
}
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,

View File

@@ -64,7 +64,10 @@ export type {
AgentHarnessSupport,
AgentHarnessSupportContext,
} from "../agents/harness/types.js";
export { AgentHarnessSessionSupersededError } from "../agents/harness/errors.js";
export {
AgentHarnessPreflightError,
AgentHarnessSessionSupersededError,
} from "../agents/harness/errors.js";
export { projectSettledTurnFinalizationAttemptResult } from "../agents/harness/settled-turn-finalization-result.js";
export const agentHarnessAttemptTerminal = {
merge: mergeAgentRunAttemptTerminal,