mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user