fix(codex): prefer desktop app-server for Computer Use on macOS (#96730)

* fix(codex): prefer desktop app-server for Computer Use on macOS

* fix(codex): fall back from stale desktop app-server

---------

Co-authored-by: Benjamin Badejo <ben@benbadejo.com>
This commit is contained in:
Ben Badejo
2026-06-25 21:28:20 +03:00
committed by GitHub
parent 5715b55000
commit b3ac552c82
7 changed files with 230 additions and 58 deletions
+18 -1
View File
@@ -115,6 +115,17 @@ 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.
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 installed desktop app
initializes with an unsupported app-server version, OpenClaw closes that child
and retries the next managed binary candidate instead of letting a stale
desktop app shadow the plugin-local fallback. Explicit `appServer.command`
config or `OPENCLAW_CODEX_APP_SERVER_BIN` still overrides this managed
selection.
## Commands
Use the `/codex computer-use` commands from any chat surface where the `codex`
@@ -276,7 +287,13 @@ Codex app-server MCP status, or macOS permissions.
**Status or a probe times out on `computer-use.list_apps`.** The plugin and 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.
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:
```text
/codex computer-use install --source /Applications/Codex.app/Contents/Resources/plugins/openai-bundled
```
**A Computer Use tool says `Native hook relay unavailable`.** The Codex-native
tool hook could not reach an active OpenClaw relay through the local bridge or
@@ -639,6 +639,15 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse)
return detectedVersion;
}
export function isUnsupportedCodexAppServerVersionError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.startsWith(
`Codex app-server ${MIN_CODEX_APP_SERVER_VERSION} or newer is required`,
)
);
}
function buildCodexAppServerRuntimeIdentity(
response: CodexInitializeResponse,
serverVersion: string,
+5 -1
View File
@@ -167,6 +167,7 @@ export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
commandSource?: CodexAppServerCommandSource;
managedFallbackCommandPaths?: string[];
args: string[];
url?: string;
authToken?: string;
@@ -332,7 +333,9 @@ const codexAppServerNetworkProxySchema = z
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
unixSockets: z
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
.optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
@@ -874,6 +877,7 @@ export function codexAppServerStartOptionsKey(
transport: options.transport,
command: options.command,
commandSource: options.commandSource ?? null,
managedFallbackCommandPaths: [...(options.managedFallbackCommandPaths ?? [])],
args: options.args,
url: options.url ?? null,
authToken: hashSecretForKey(options.authToken, "authToken"),
@@ -27,6 +27,8 @@ function managedCommandPath(root: string, platform: NodeJS.Platform): string {
return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex");
}
const MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND = "/Applications/Codex.app/Contents/Resources/codex";
describe("managed Codex app-server binary", () => {
it("leaves explicit command overrides unchanged", async () => {
const explicitOptions = startOptions("config");
@@ -41,10 +43,14 @@ describe("managed Codex app-server binary", () => {
expect(pathExists).not.toHaveBeenCalled();
});
it("resolves the plugin-local bundled Codex binary", async () => {
it("prefers the macOS desktop app bundle when it exists", async () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const paths = resolveManagedCodexAppServerPaths({ platform: "darwin", pluginRoot });
const pathExists = vi.fn(async (filePath: string) => filePath === paths.commandPath);
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(
async (filePath: string) =>
filePath === MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND || filePath === pluginLocalCommand,
);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
@@ -54,10 +60,31 @@ describe("managed Codex app-server binary", () => {
}),
).resolves.toEqual({
...startOptions("managed"),
command: paths.commandPath,
command: MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND,
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 () => {
const pluginRoot = path.join("/tmp", "openclaw", "extensions", "codex");
const pluginLocalCommand = managedCommandPath(pluginRoot, "darwin");
const pathExists = vi.fn(async (filePath: string) => filePath === pluginLocalCommand);
await expect(
resolveManagedCodexAppServerStartOptions(startOptions("managed"), {
platform: "darwin",
pluginRoot,
pathExists,
}),
).resolves.toEqual({
...startOptions("managed"),
command: pluginLocalCommand,
commandSource: "resolved-managed",
});
expect(paths.commandPath).toBe(managedCommandPath(pluginRoot, "darwin"));
expect(pathExists).toHaveBeenCalledWith(MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND, "darwin");
});
it("resolves Windows Codex command shims", () => {
@@ -12,6 +12,7 @@ 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";
type ManagedCodexAppServerPaths = {
commandPath: string;
@@ -39,16 +40,19 @@ export async function resolveManagedCodexAppServerStartOptions(
pluginRoot: options.pluginRoot,
});
const pathExists = options.pathExists ?? commandPathExists;
const commandPath = await findManagedCodexAppServerCommandPath({
const commandPaths = await findManagedCodexAppServerCommandPaths({
candidateCommandPaths: paths.candidateCommandPaths,
pathExists,
platform,
});
const commandPath = commandPaths[0];
const managedFallbackCommandPaths = commandPaths.slice(1);
return {
...startOptions,
command: commandPath,
commandSource: "resolved-managed",
...(managedFallbackCommandPaths.length > 0 ? { managedFallbackCommandPaths } : {}),
};
}
@@ -77,12 +81,17 @@ function resolveManagedCodexAppServerCommandCandidates(
const roots = resolveManagedCodexAppServerCandidateRoots(pluginRoot, platform);
return [
...new Set([
...resolveDesktopCodexAppServerCommandCandidates(platform),
...roots.map((root) => pathApi.join(root, "node_modules", ".bin", commandName)),
...resolveManagedCodexPackageBinCandidates(roots, platform),
]),
];
}
function resolveDesktopCodexAppServerCommandCandidates(platform: NodeJS.Platform): string[] {
return platform === "darwin" ? [MACOS_DESKTOP_CODEX_APP_SERVER_COMMAND] : [];
}
function resolveDefaultCodexPluginRoot(moduleDir: string): string {
const moduleBaseName = path.basename(moduleDir);
if (moduleBaseName === "dist" || moduleBaseName === "dist-runtime") {
@@ -195,16 +204,20 @@ function pathForPlatform(platform: NodeJS.Platform): typeof path {
return platform === "win32" ? path.win32 : path.posix;
}
async function findManagedCodexAppServerCommandPath(params: {
async function findManagedCodexAppServerCommandPaths(params: {
candidateCommandPaths: readonly string[];
pathExists: (filePath: string, platform: NodeJS.Platform) => Promise<boolean>;
platform: NodeJS.Platform;
}): Promise<string> {
}): Promise<string[]> {
const commandPaths: string[] = [];
for (const commandPath of params.candidateCommandPaths) {
if (await params.pathExists(commandPath, params.platform)) {
return commandPath;
commandPaths.push(commandPath);
}
}
if (commandPaths.length > 0) {
return commandPaths;
}
throw new Error(
[
@@ -187,6 +187,41 @@ describe("shared Codex app-server client", () => {
startSpy.mockRestore();
});
it("falls back to the next managed app-server when desktop initialize is unsupported", async () => {
const desktop = createClientHarness();
const pluginLocal = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(desktop.client)
.mockReturnValueOnce(pluginLocal.client);
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => ({
...startOptions,
command: "/Applications/Codex.app/Contents/Resources/codex",
commandSource: "resolved-managed",
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
}));
const listPromise = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(desktop, "openclaw/0.124.9 (macOS; test)");
await sendInitializeResult(pluginLocal, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(pluginLocal);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(desktop.process.stdin.destroyed).toBe(true);
expect(pluginLocal.process.stdin.destroyed).toBe(false);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(startSpy.mock.calls[0]?.[0]).toMatchObject({
command: "/Applications/Codex.app/Contents/Resources/codex",
commandSource: "resolved-managed",
managedFallbackCommandPaths: ["/cache/openclaw/codex"],
});
expect(startSpy.mock.calls[1]?.[0]).toMatchObject({
command: "/cache/openclaw/codex",
commandSource: "resolved-managed",
});
expect(startSpy.mock.calls[1]?.[0]).not.toHaveProperty("managedFallbackCommandPaths");
});
it("closes and clears a shared app-server when initialize times out", async () => {
const first = createClientHarness();
const second = createClientHarness();
+115 -48
View File
@@ -11,7 +11,7 @@ import {
resolveCodexAppServerAuthProfileStore,
resolveCodexAppServerFallbackApiKeyCacheKey,
} from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js";
import {
codexAppServerStartOptionsKey,
resolveCodexAppServerRuntimeOptions,
@@ -242,27 +242,23 @@ async function acquireSharedCodexAppServerClient(
const sharedPromise =
entry.promise ??
(entry.promise = (async () => {
const client = CodexAppServerClient.start(startOptions);
const client = await startInitializedCodexAppServerClient({
startOptions,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
config: options?.config,
onStartedClient: (startedClient) => {
entry.client = startedClient;
startedClient.setActiveSharedLeaseCountProviderForUnscopedNotifications(
() => entry.activeLeases,
);
options?.onStartedClient?.(startedClient);
},
});
entry.client = client;
options?.onStartedClient?.(client);
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
try {
await client.initialize();
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
});
return client;
} catch (error) {
// Startup failures happen before callers own the shared client, so close
// the child here instead of leaving a rejected daemon attached to stdio.
client.close();
throw error;
}
return client;
})());
try {
const client = await withTimeout(
@@ -291,39 +287,110 @@ export async function createIsolatedCodexAppServerClient(
): Promise<CodexAppServerClient> {
const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } =
await resolveCodexAppServerClientStartContext(options);
const client = CodexAppServerClient.start(startOptions);
if (authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
return await startInitializedCodexAppServerClient({
startOptions,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
authProfileStore,
config: options?.config,
timeoutMs: options?.timeoutMs,
onStartedClient: options?.onStartedClient,
});
}
async function startInitializedCodexAppServerClient(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId: string | null | undefined;
authProfileStore?: AuthProfileStore;
config?: CodexAppServerClientOptions["config"];
timeoutMs?: number;
onStartedClient?: (client: CodexAppServerClient) => void;
}): Promise<CodexAppServerClient> {
const startOptionsCandidates = resolveManagedFallbackStartOptions(params.startOptions);
for (let index = 0; index < startOptionsCandidates.length; index += 1) {
const startOptions = startOptionsCandidates[index];
const client = CodexAppServerClient.start(startOptions);
params.onStartedClient?.(client);
const initialize = client.initialize();
try {
await withTimeout(initialize, params.timeoutMs ?? 0, "codex app-server initialize timed out");
} catch (error) {
client.close();
void initialize.catch(() => undefined);
if (shouldTryManagedFallbackStartOption(error, startOptions, index, startOptionsCandidates)) {
continue;
}
return await refreshCodexAppServerAuthTokens({
agentDir,
authProfileId,
...(authProfileStore ? { authProfileStore } : {}),
config: options?.config,
throw error;
}
if (params.authProfileId) {
// Profile-backed Codex auth is ephemeral. Keep the host refresh callback
// available whether the profile came from a scoped store or persisted state.
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return await refreshCodexAppServerAuthTokens({
agentDir: params.agentDir,
authProfileId: params.authProfileId!,
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
config: params.config,
});
});
});
}
try {
await applyCodexAppServerAuthProfile({
client,
agentDir: params.agentDir,
authProfileId: params.authProfileId,
startOptions,
config: params.config,
...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}),
});
return client;
} catch (error) {
client.close();
throw error;
}
}
const initialize = client.initialize();
try {
await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out");
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: usesNativeAuth ? null : authProfileId,
startOptions,
config: options?.config,
...(authProfileStore ? { authProfileStore } : {}),
});
return client;
} catch (error) {
client.close();
void initialize.catch(() => undefined);
throw error;
throw new Error("Managed Codex app-server fallback candidates were exhausted.");
}
function resolveManagedFallbackStartOptions(
startOptions: CodexAppServerStartOptions,
): CodexAppServerStartOptions[] {
const commands = [startOptions.command, ...(startOptions.managedFallbackCommandPaths ?? [])];
const candidates: CodexAppServerStartOptions[] = [];
for (let index = 0; index < commands.length; index += 1) {
const command = commands[index];
const managedFallbackCommandPaths = commands.slice(index + 1);
const candidate = {
...startOptions,
command,
};
if (managedFallbackCommandPaths.length === 0) {
delete candidate.managedFallbackCommandPaths;
} else {
candidate.managedFallbackCommandPaths = managedFallbackCommandPaths;
}
candidates.push(candidate);
}
return candidates;
}
function shouldTryManagedFallbackStartOption(
error: unknown,
startOptions: CodexAppServerStartOptions,
index: number,
startOptionsCandidates: readonly CodexAppServerStartOptions[],
): boolean {
return (
startOptions.commandSource === "resolved-managed" &&
index < startOptionsCandidates.length - 1 &&
isUnsupportedCodexAppServerVersionError(error)
);
}
/** Clears and closes all shared clients for deterministic tests. */