fix(codex): prefer ChatGPT desktop resources (#103470)

Co-authored-by: Hiroshi Tanaka <solavrc@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-10 07:44:24 +01:00
committed by GitHub
parent 9e74dabad2
commit 0f3fb9ea58
5 changed files with 233 additions and 54 deletions
+19 -12
View File
@@ -107,17 +107,20 @@ 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
marketplace is registered and the standard Codex app bundle exists, OpenClaw
marketplace is registered and a standard desktop app bundle exists, OpenClaw
also tries to register the bundled Codex marketplace from
`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled` before it
fails. If setup still cannot make the MCP server available, the turn fails
before the thread starts.
`/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.
After changing Computer Use config, use `/new` or `/reset` in the affected
chat before testing if an existing Codex thread has already started.
On macOS managed stdio startup, OpenClaw prefers the signed desktop Codex app
bundle at `/Applications/Codex.app/Contents/Resources/codex` when it exists.
On macOS managed stdio startup, OpenClaw prefers the signed desktop app
bundle at `/Applications/ChatGPT.app/Contents/Resources/codex`, then falls
back to `/Applications/Codex.app/Contents/Resources/codex` for legacy
standalone installs.
That keeps Computer Use under the app bundle that owns the local
desktop-control permissions. If the desktop app is not installed, OpenClaw
falls back to the managed Codex binary installed beside the plugin. If an
@@ -177,24 +180,27 @@ matches fail closed and ask you to set `marketplaceName` or
## Bundled macOS marketplace
Recent Codex desktop builds bundle Computer Use here:
Current ChatGPT desktop builds bundle Computer Use here; legacy standalone
Codex desktop builds use the same layout under `Codex.app`:
```text
/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use
/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use
```
When `computerUse.autoInstall` is true and no marketplace containing
`computer-use` is registered, OpenClaw tries to add the standard bundled
marketplace root automatically:
`computer-use` is registered, OpenClaw tries to add the first standard
bundled marketplace root that exists:
```text
/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled
/Applications/Codex.app/Contents/Resources/plugins/openai-bundled
```
You can also register it explicitly from a shell with Codex:
```bash
codex plugin marketplace add /Applications/Codex.app/Contents/Resources/plugins/openai-bundled
codex plugin marketplace add /Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled
```
If you use a nonstandard Codex app path, run `/codex computer-use install
@@ -311,10 +317,11 @@ MCP server are present, but the local Computer Use bridge did not answer.
Quit or restart Codex Computer Use, relaunch Codex Desktop if needed, then
retry in a fresh OpenClaw session. If the host previously ran Computer Use
through an older managed Codex app-server, refresh the installed plugin from
the desktop bundled marketplace:
the desktop bundled marketplace (use the `Codex.app` path for standalone
Codex desktop installs):
```text
/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled
/codex computer-use install --source /Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled
```
**A Computer Use tool says `Native hook relay unavailable`.** The
@@ -247,6 +247,32 @@ describe("Codex Computer Use setup", () => {
expectRequestMethodNotCalled(request, "plugin/install");
});
it("does not inspect bundled app paths when a registered marketplace is ready", async () => {
const request = createComputerUseRequest({ installed: true });
const forbiddenCandidates = new Proxy(["/unused/bundled-marketplace"], {
get() {
throw new Error("bundled marketplace candidates must stay lazy");
},
});
const status = await ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
},
},
request,
defaultBundledMarketplacePaths: forbiddenCandidates,
});
expectStatusFields(status, {
ready: true,
reason: "ready",
});
expectRequestMethodNotCalled(request, "marketplace/add");
});
it("uses setup writes when auto-install needs to install", async () => {
const request = createComputerUseRequest({ installed: false });
@@ -280,6 +306,9 @@ describe("Codex Computer Use setup", () => {
path.join(os.tmpdir(), "openclaw-codex-bundled-marketplace-"),
);
cleanupPaths.push(bundledMarketplacePath);
fs.mkdirSync(path.join(bundledMarketplacePath, "plugins", "computer-use"), {
recursive: true,
});
const request = createBundledMarketplaceComputerUseRequest(bundledMarketplacePath);
const status = await ensureCodexComputerUse({
@@ -290,7 +319,7 @@ describe("Codex Computer Use setup", () => {
},
},
request,
defaultBundledMarketplacePath: bundledMarketplacePath,
defaultBundledMarketplacePaths: [bundledMarketplacePath],
});
expectStatusFields(status, {
@@ -308,6 +337,90 @@ describe("Codex Computer Use setup", () => {
});
});
it.each([
{
label: "prefers ChatGPT.app when both desktop marketplaces exist",
marketplaceIndexes: [0, 1],
pluginIndexes: [0, 1],
expectedIndex: 0,
},
{
label: "uses ChatGPT.app when it is the only desktop marketplace",
marketplaceIndexes: [0],
pluginIndexes: [0],
expectedIndex: 0,
},
{
label: "falls back to legacy Codex.app when it is the only desktop marketplace",
marketplaceIndexes: [1],
pluginIndexes: [1],
expectedIndex: 1,
},
{
label: "skips a ChatGPT.app marketplace that does not contain Computer Use",
marketplaceIndexes: [0, 1],
pluginIndexes: [1],
expectedIndex: 1,
},
{
label: "does not add a marketplace when neither desktop bundle contains Computer Use",
marketplaceIndexes: [0, 1],
pluginIndexes: [],
expectedIndex: undefined,
},
])("$label", async ({ marketplaceIndexes, pluginIndexes, expectedIndex }) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-desktop-bundles-"));
cleanupPaths.push(root);
const bundledMarketplacePaths = [
path.join(root, "ChatGPT.app", "Contents", "Resources", "plugins", "openai-bundled"),
path.join(root, "Codex.app", "Contents", "Resources", "plugins", "openai-bundled"),
];
for (const index of marketplaceIndexes) {
fs.mkdirSync(bundledMarketplacePaths[index], { recursive: true });
}
for (const index of pluginIndexes) {
fs.mkdirSync(path.join(bundledMarketplacePaths[index], "plugins", "computer-use"), {
recursive: true,
});
}
const expectedPath =
expectedIndex === undefined ? undefined : bundledMarketplacePaths[expectedIndex];
const request = expectedPath
? createBundledMarketplaceComputerUseRequest(expectedPath)
: createEmptyMarketplaceComputerUseRequest();
const setup = ensureCodexComputerUse({
pluginConfig: {
computerUse: {
enabled: true,
autoInstall: true,
marketplaceDiscoveryTimeoutMs: 1,
},
},
request,
defaultBundledMarketplacePaths: bundledMarketplacePaths,
});
if (!expectedPath) {
await expectSetupErrorStatus(setup, {
ready: false,
reason: "marketplace_missing",
});
expectRequestMethodNotCalled(request, "marketplace/add");
return;
}
const status = await setup;
expectStatusFields(status, {
ready: true,
reason: "ready",
marketplaceName: "openai-bundled",
});
expect(request).toHaveBeenCalledWith("marketplace/add", {
source: expectedPath,
});
});
it("allows auto-install from a configured local marketplace path", async () => {
const request = createComputerUseRequest({ installed: false });
@@ -601,6 +714,9 @@ function createAmbiguousComputerUseRequest(): CodexComputerUseRequest {
function createEmptyMarketplaceComputerUseRequest(): CodexComputerUseRequest {
return vi.fn(async (method: string) => {
if (method === "experimentalFeature/enablement/set") {
return { enablement: { plugins: true } };
}
if (method === "plugin/list") {
return {
marketplaces: [],
+38 -35
View File
@@ -3,6 +3,7 @@
* app-server sessions.
*/
import { existsSync } from "node:fs";
import path from "node:path";
import { describeControlFailure } from "./capabilities.js";
import type { CodexAppServerClient } from "./client.js";
import {
@@ -74,7 +75,7 @@ export type CodexComputerUseSetupParams = {
timeoutMs?: number;
signal?: AbortSignal;
forceEnable?: boolean;
defaultBundledMarketplacePath?: string;
defaultBundledMarketplacePaths?: readonly string[];
};
type MarketplaceRef =
@@ -106,8 +107,11 @@ type PluginInspection =
const CURATED_MARKETPLACE_POLL_INTERVAL_MS = 2_000;
const COMPUTER_USE_MARKETPLACE_NAME_PRIORITY = ["openai-bundled", "openai-curated", "local"];
const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH =
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled";
// ChatGPT.app is the current desktop owner; keep Codex.app as the legacy fallback.
const DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS = [
"/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled",
"/Applications/Codex.app/Contents/Resources/plugins/openai-bundled",
] as const;
/** Reads Computer Use readiness without installing or mutating app-server state. */
export async function readCodexComputerUseStatus(
@@ -200,7 +204,7 @@ async function inspectCodexComputerUse(params: {
signal?: AbortSignal;
config: ResolvedCodexComputerUseConfig;
installPlugin: boolean;
defaultBundledMarketplacePath?: string;
defaultBundledMarketplacePaths?: readonly string[];
}): Promise<CodexComputerUseStatus> {
const request = createComputerUseRequest(params);
if (params.installPlugin) {
@@ -214,7 +218,7 @@ async function inspectCodexComputerUse(params: {
config: params.config,
allowAdd: params.installPlugin,
signal: params.signal,
defaultBundledMarketplacePath: params.defaultBundledMarketplacePath,
defaultBundledMarketplacePaths: params.defaultBundledMarketplacePaths,
});
if (!marketplace.marketplace) {
return unavailableStatus(
@@ -340,7 +344,7 @@ async function resolveMarketplaceRef(params: {
config: ResolvedCodexComputerUseConfig;
allowAdd: boolean;
signal?: AbortSignal;
defaultBundledMarketplacePath?: string;
defaultBundledMarketplacePaths?: readonly string[];
}): Promise<MarketplaceResolution> {
let preferredMarketplaceName = params.config.marketplaceName;
if (params.config.marketplaceSource && params.allowAdd) {
@@ -358,14 +362,27 @@ async function resolveMarketplaceRef(params: {
}
let candidates = await listComputerUseMarketplaceCandidates(params.request, params.config);
if (candidates.length === 0 && shouldAddBundledComputerUseMarketplace(params)) {
const bundledMarketplacePath =
params.defaultBundledMarketplacePath ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH;
const added = await params.request<{ marketplaceName?: string }>("marketplace/add", {
source: bundledMarketplacePath,
} satisfies CodexRequestObject);
preferredMarketplaceName ??= added.marketplaceName;
candidates = await listComputerUseMarketplaceCandidates(params.request, params.config);
if (
candidates.length === 0 &&
params.allowAdd &&
usesDefaultMarketplaceDiscovery(params.config)
) {
// Most turns already have a registered marketplace. Probe app bundles only
// on the empty auto-install path to keep ordinary startup free of filesystem I/O.
const bundledMarketplacePath = (
params.defaultBundledMarketplacePaths ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATHS
).find((candidatePath) =>
// The signed desktop bundles publish plugins under this fixed marketplace layout.
// Check the requested plugin before registering a source that would shadow the fallback.
existsSync(path.join(candidatePath, "plugins", params.config.pluginName)),
);
if (bundledMarketplacePath) {
const added = await params.request<{ marketplaceName?: string }>("marketplace/add", {
source: bundledMarketplacePath,
} satisfies CodexRequestObject);
preferredMarketplaceName ??= added.marketplaceName;
candidates = await listComputerUseMarketplaceCandidates(params.request, params.config);
}
}
const waitUntil = marketplaceDiscoveryWaitUntil(params);
@@ -405,7 +422,10 @@ async function resolveMarketplaceRef(params: {
};
}
const marketplace = candidates[0];
return marketplace ? { marketplace } : {};
if (marketplace) {
return { marketplace };
}
return {};
}
async function listComputerUseMarketplaceCandidates(
@@ -431,20 +451,8 @@ function blockUnsafeAutoInstallStatus(
);
}
function shouldAddBundledComputerUseMarketplace(params: {
config: ResolvedCodexComputerUseConfig;
allowAdd: boolean;
defaultBundledMarketplacePath?: string;
}): boolean {
const bundledMarketplacePath =
params.defaultBundledMarketplacePath ?? DEFAULT_CODEX_BUNDLED_MARKETPLACE_PATH;
return (
params.allowAdd &&
!params.config.marketplaceSource &&
!params.config.marketplacePath &&
!params.config.marketplaceName &&
existsSync(bundledMarketplacePath)
);
function usesDefaultMarketplaceDiscovery(config: ResolvedCodexComputerUseConfig): boolean {
return !config.marketplaceSource && !config.marketplacePath && !config.marketplaceName;
}
function findComputerUseMarketplaces(
@@ -484,12 +492,7 @@ function marketplaceDiscoveryWaitUntil(params: {
config: ResolvedCodexComputerUseConfig;
allowAdd: boolean;
}): number {
if (
params.allowAdd &&
!params.config.marketplaceSource &&
!params.config.marketplacePath &&
!params.config.marketplaceName
) {
if (params.allowAdd && usesDefaultMarketplaceDiscovery(params.config)) {
return Date.now() + params.config.marketplaceDiscoveryTimeoutMs;
}
return 0;
@@ -28,6 +28,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string {
}
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
const MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND =
"/Applications/ChatGPT.app/Contents/Resources/codex";
describe("managed Codex app-server binary", () => {
it("leaves explicit command overrides unchanged", async () => {
@@ -43,10 +45,58 @@ describe("managed Codex app-server binary", () => {
expect(pathExists).not.toHaveBeenCalled();
});
it("prefers the macOS desktop app bundle when it exists", async () => {
it("prefers ChatGPT.app when both macOS desktop bundles exist", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot });
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(
async (filePath: string) =>
filePath === MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND ||
filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND ||
filePath === pluginLocalCommand,
);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND,
commandSource: "resolved-managed",
managedFallbackCommandPaths: [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, pluginLocalCommand],
});
expect(paths.commandPath).toBe(MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND);
expect(paths.candidateCommandPaths).toContain(pluginLocalCommand);
});
it("prefers the ChatGPT.app desktop bundle when Codex.app is absent", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(
async (filePath: string) =>
filePath === MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND || filePath === pluginLocalCommand,
);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND,
commandSource: "resolved-managed",
managedFallbackCommandPaths: [pluginLocalCommand],
});
});
it("falls back to the legacy Codex.app desktop bundle when ChatGPT.app is absent", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(
async (filePath: string) =>
filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand,
@@ -64,11 +114,9 @@ describe("managed Codex app-server binary", () => {
commandSource: "resolved-managed",
managedFallbackCommandPaths: [pluginLocalCommand],
});
expect(paths.commandPath).toBe(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND);
expect(paths.candidateCommandPaths).toContain(pluginLocalCommand);
});
it("falls back to the plugin-local bundled Codex binary on macOS", async () => {
it("falls back to the plugin-local binary when neither desktop bundle exists", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand);
@@ -84,6 +132,7 @@ describe("managed Codex app-server binary", () => {
command: pluginLocalCommand,
commandSource: "resolved-managed",
});
expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CHATGPT_APP_SERVER_COMMAND, "darwin");
expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin");
});
@@ -12,7 +12,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE } from "./version.js";
const CODEX_APP_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const CODEX_PLUGIN_ROOT = resolveDefaultCodexPluginRoot(CODEX_APP_SERVER_MODULE_DIR);
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
// ChatGPT.app is the current desktop owner; keep Codex.app as the legacy fallback.
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS = [
"/Applications/ChatGPT.app/Contents/Resources/codex",
"/Applications/Codex.app/Contents/Resources/codex",
] as const;
type ManagedCodexAppServerPaths = {
commandPath: string;
@@ -89,7 +93,7 @@ function resolveManagedCodexAppServerCommandCandidates(
}
function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] {
return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : [];
return platform === "darwin" ? [...MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS] : [];
}
function resolveDefaultCodexPluginRoot(moduleDir: string): string {