diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 59a02eb54207..a4cc3c627997 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -85,26 +85,27 @@ For an already-running app-server, use WebSocket transport: Supported `appServer` fields: -| Field | Default | Meaning | -| --------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | -| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. | -| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | -| `url` | unset | WebSocket app-server URL. | -| `authToken` | unset | Bearer token for WebSocket transport. | -| `headers` | `{}` | Extra WebSocket headers. | -| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. | -| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. | -| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. | -| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. | -| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. | -| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. | -| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. | -| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. | -| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. | -| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. | -| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions..network` config and selects it with `default_permissions` instead of sending `sandbox`. | -| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. | +| Field | Default | Meaning | +| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | +| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. | +| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | +| `url` | unset | WebSocket app-server URL. | +| `authToken` | unset | Bearer token for WebSocket transport. | +| `headers` | `{}` | Extra WebSocket headers. | +| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. | +| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. | +| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. | +| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. | +| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. | +| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. | +| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. | +| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. | +| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. | +| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. | +| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. | +| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions..network` config and selects it with `default_permissions` instead of sending `sandbox`. | +| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. | `appServer.networkProxy` is explicit because it changes the Codex sandbox contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and @@ -146,6 +147,14 @@ so a full-access profile would not protect outbound traffic. The plugin blocks older or unversioned app-server handshakes. Codex app-server must report stable version `0.125.0` or newer. +OpenClaw treats non-loopback WebSocket app-server URLs as remote and requires +identity-bearing WebSocket auth through `appServer.authToken` or an +`Authorization` header. When native Codex plugins are configured, OpenClaw uses +the connected app-server's plugin control plane to install or refresh those +plugins and then refreshes app inventory so plugin-owned apps are visible to the +Codex thread. Only connect OpenClaw to remote app-servers that are trusted to +accept OpenClaw-managed plugin installs and app inventory refreshes. + ## Approval and sandbox modes Local stdio app-server sessions default to YOLO mode: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 94922b166210..e44d396a12c4 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -463,6 +463,9 @@ Explicit Codex API-key profiles and local stdio env-key fallback use app-server login instead of inherited child-process env. WebSocket app-server connections do not receive Gateway env API-key fallback; use an explicit auth profile or the remote app-server's own account. +When native Codex plugins are configured, OpenClaw installs or refreshes those +plugins through the connected app-server before exposing plugin-owned apps to +the Codex thread. If a subscription profile hits a Codex usage limit, OpenClaw records the reset time when Codex reports one and tries the next ordered auth profile for the same @@ -543,26 +546,27 @@ Supported top-level Codex plugin fields: Supported `appServer` fields: -| Field | Default | Meaning | -| --------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | -| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. | -| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | -| `url` | unset | WebSocket app-server URL. | -| `authToken` | unset | Bearer token for WebSocket transport. | -| `headers` | `{}` | Extra WebSocket headers. | -| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. | -| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. | -| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. | -| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. | -| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. | -| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. | -| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. | -| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. | -| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. | -| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. | -| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions..network` config and selects it with `default_permissions` instead of sending `sandbox`. | -| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. | +| Field | Default | Meaning | +| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. | +| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. | +| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. | +| `url` | unset | WebSocket app-server URL. | +| `authToken` | unset | Bearer token for WebSocket transport. | +| `headers` | `{}` | Extra WebSocket headers. | +| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. | +| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. | +| `remoteWorkspaceRoot` | unset | Remote Codex app-server workspace root. When set, OpenClaw infers the local workspace root from the resolved OpenClaw workspace, preserves the current cwd suffix under this remote root, and sends only the final app-server cwd to Codex. If the cwd is outside the resolved OpenClaw workspace root, OpenClaw fails closed instead of sending a gateway-local path to the remote app-server. | +| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. | +| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. | +| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, post-tool raw assistant progress, raw reasoning completion, or reasoning progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. | +| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. | +| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. | +| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. | +| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. | +| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. | +| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions..network` config and selects it with `default_permissions` instead of sending `sandbox`. | +| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. | `appServer.networkProxy` is explicit because it changes the Codex sandbox contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` and diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 7674724df508..175731e34638 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -161,6 +161,9 @@ "type": "array", "items": { "type": "string" } }, + "remoteWorkspaceRoot": { + "type": "string" + }, "codeModeOnly": { "type": "boolean", "default": false @@ -386,6 +389,11 @@ "help": "Environment variable names removed from the spawned stdio app-server process after overrides are applied.", "advanced": true }, + "appServer.remoteWorkspaceRoot": { + "label": "Remote Workspace Root", + "help": "Remote Codex app-server workspace root used to project OpenClaw cwd suffixes before starting Codex threads.", + "advanced": true + }, "appServer.codeModeOnly": { "label": "Code Mode Only", "help": "Expose Codex's code-mode-only tool surface. OpenClaw dynamic tools remain available through Codex nested tool calls.", diff --git a/extensions/codex/src/app-server/app-inventory-cache.test.ts b/extensions/codex/src/app-server/app-inventory-cache.test.ts index 1883b8378efa..e0f9c1af96c6 100644 --- a/extensions/codex/src/app-server/app-inventory-cache.test.ts +++ b/extensions/codex/src/app-server/app-inventory-cache.test.ts @@ -53,6 +53,35 @@ describe("Codex app inventory cache", () => { expect(request).not.toHaveBeenCalled(); }); + it("stops paginated refresh once target app ids are found", async () => { + const cache = new CodexAppInventoryCache({ ttlMs: 100 }); + const request = vi.fn(async (_method: "app/list", params: v2.AppsListParams) => { + if (!params.cursor) { + return { data: [app("app-1")], nextCursor: "page-2" } satisfies v2.AppsListResponse; + } + if (params.cursor === "page-2") { + return { + data: [app("google-calendar-app")], + nextCursor: "page-3", + } satisfies v2.AppsListResponse; + } + return { data: [app("app-3")], nextCursor: null } satisfies v2.AppsListResponse; + }); + + const snapshot = await cache.refreshNow({ + key: "runtime", + request, + targetAppIds: ["google-calendar-app"], + }); + + expect(snapshot.apps.map((item) => item.id)).toEqual(["app-1", "google-calendar-app"]); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls.map(([, params]) => params.cursor ?? null)).toEqual([ + null, + "page-2", + ]); + }); + it("uses stale inventory for the current read while still refreshing asynchronously", async () => { const cache = new CodexAppInventoryCache({ ttlMs: 10 }); const request = vi.fn(async () => { diff --git a/extensions/codex/src/app-server/app-inventory-cache.ts b/extensions/codex/src/app-server/app-inventory-cache.ts index d2293c5cd885..e6e608080e34 100644 --- a/extensions/codex/src/app-server/app-inventory-cache.ts +++ b/extensions/codex/src/app-server/app-inventory-cache.ts @@ -25,6 +25,7 @@ export type CodexAppInventoryRequest = ( export type CodexAppInventoryCacheKeyInput = { codexHome?: string; endpoint?: string; + runtimeIdentity?: Record; authProfileId?: string; accountId?: string; envApiKeyFingerprint?: string; @@ -70,6 +71,7 @@ type RefreshParams = { nowMs?: number; forceRefetch?: boolean; suppressRefresh?: boolean; + targetAppIds?: readonly string[]; }; /** In-memory app inventory cache with coalesced refreshes per key. */ @@ -189,7 +191,11 @@ export class CodexAppInventoryCache { ): Promise { const nowMs = resolveDateTimestampMs(params.nowMs); try { - const apps = await listAllApps(params.request, params.forceRefetch ?? false); + const apps = await listAllApps( + params.request, + params.forceRefetch ?? false, + params.targetAppIds, + ); this.revision += 1; const expiresAtMs = resolveExpiresAtMsFromDurationMs(this.ttlMs, { nowMs }) ?? 0; const snapshot: CodexAppInventorySnapshot = { @@ -251,6 +257,7 @@ export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyI return JSON.stringify({ codexHome: input.codexHome ?? null, endpoint: input.endpoint ?? null, + runtimeIdentity: normalizeRuntimeIdentityForCacheKey(input.runtimeIdentity), authProfileId: input.authProfileId ?? null, accountId: input.accountId ?? null, envApiKeyFingerprint: input.envApiKeyFingerprint ?? null, @@ -258,11 +265,29 @@ export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyI }); } +function normalizeRuntimeIdentityForCacheKey( + value: Record | undefined, +): Record | null { + if (!value) { + return null; + } + const entries = Object.entries(value) + .flatMap(([key, rawValue]) => { + const normalized = rawValue?.trim(); + return normalized ? ([[key, normalized]] as const) : []; + }) + .toSorted(([left], [right]) => left.localeCompare(right)); + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + async function listAllApps( request: CodexAppInventoryRequest, forceRefetch: boolean, + targetAppIds: readonly string[] = [], ): Promise { const apps: v2.AppInfo[] = []; + const targetIds = new Set(targetAppIds.filter(Boolean)); + const foundTargetIds = new Set(); let cursor: string | null | undefined; do { const response = await request("app/list", { @@ -271,7 +296,15 @@ async function listAllApps( forceRefetch, }); apps.push(...response.data); + for (const app of response.data) { + if (targetIds.has(app.id)) { + foundTargetIds.add(app.id); + } + } cursor = response.nextCursor; + if (targetIds.size > 0 && foundTargetIds.size === targetIds.size) { + break; + } } while (cursor); return apps; } diff --git a/extensions/codex/src/app-server/attempt-diagnostics.test.ts b/extensions/codex/src/app-server/attempt-diagnostics.test.ts index 88012aa6293e..300c92509f9f 100644 --- a/extensions/codex/src/app-server/attempt-diagnostics.test.ts +++ b/extensions/codex/src/app-server/attempt-diagnostics.test.ts @@ -29,6 +29,8 @@ describe("Codex app-server attempt diagnostics", () => { approvalPolicy: "never" as const, approvalsReviewer: "user" as const, sandbox: "danger-full-access" as const, + connectionClass: "local-loopback" as const, + remoteAppsSubstrate: "preconfigured" as const, serviceTier: "priority" as const, }; const resolvedPluginPolicy = resolveCodexPluginsPolicy({ diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index c6e95a791f5a..7db8f266513e 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -30,7 +30,10 @@ import { resolveCodexSandboxEnvironmentSelection, shouldRequireCodexSandboxExecServerEnvironment, } from "./dynamic-tool-build.js"; -import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js"; +import { + buildCodexAppServerRuntimeFingerprint, + buildCodexPluginAppCacheKey, +} from "./plugin-app-cache-key.js"; import { buildCodexPluginThreadConfig, buildCodexPluginThreadConfigInputFingerprint, @@ -148,20 +151,6 @@ export async function startCodexAttemptThread(params: { const pluginThreadConfigPluginConfig = params.nativeToolSurfaceEnabled ? params.pluginConfig : disableCodexPluginThreadConfig(params.pluginConfig); - const pluginAppCacheKeyInput = { - appServer: params.appServer, - agentDir: params.agentDir, - authProfileId: params.startupAuthProfileId, - accountId: params.startupAuthAccountCacheKey, - envApiKeyFingerprint: params.startupEnvApiKeyCacheKey, - }; - const pluginAppCacheKey = buildCodexPluginAppCacheKey(pluginAppCacheKeyInput); - const pluginThreadConfigInputFingerprint = pluginThreadConfigRequired - ? buildCodexPluginThreadConfigInputFingerprint({ - pluginConfig: pluginThreadConfigPluginConfig, - appCacheKey: pluginAppCacheKey, - }) - : undefined; const resolvedPluginPolicy = pluginThreadConfigRequired ? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig) : undefined; @@ -174,20 +163,6 @@ export async function startCodexAttemptThread(params: { .map((plugin) => plugin.configKey) .toSorted() : undefined; - const attemptParams = params.buildAttemptParams(); - embeddedAgentLog.debug( - "codex plugin thread config eligibility", - buildCodexPluginThreadConfigEligibilityLogData({ - sessionId: attemptParams.sessionId, - sessionKey: attemptParams.sessionKey ?? "", - pluginThreadConfigRequired, - resolvedPluginPolicy, - enabledPluginConfigKeys, - pluginAppCacheKey, - startupAuthProfileId: params.startupAuthProfileId, - appServer: params.appServer, - }), - ); pluginAppServer = mcpElicitationDelegationRequired ? { ...params.appServer, @@ -243,6 +218,41 @@ export async function startCodexAttemptThread(params: { timeoutMs: params.appServer.requestTimeoutMs, signal: startupAbandonController.signal, }); + const startupRuntimeIdentity = activeStartupClient.getRuntimeIdentity(); + const pluginAppCacheKey = buildCodexPluginAppCacheKey({ + appServer: params.appServer, + agentDir: params.agentDir, + authProfileId: params.startupAuthProfileId, + accountId: params.startupAuthAccountCacheKey, + envApiKeyFingerprint: params.startupEnvApiKeyCacheKey, + appServerVersion: activeStartupClient.getServerVersion(), + runtimeIdentity: startupRuntimeIdentity, + }); + const appServerRuntimeFingerprint = buildCodexAppServerRuntimeFingerprint({ + appServer: params.appServer, + appServerVersion: activeStartupClient.getServerVersion(), + runtimeIdentity: startupRuntimeIdentity, + }); + const pluginThreadConfigInputFingerprint = pluginThreadConfigRequired + ? buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: pluginThreadConfigPluginConfig, + appCacheKey: pluginAppCacheKey, + }) + : undefined; + const attemptParams = params.buildAttemptParams(); + embeddedAgentLog.debug( + "codex plugin thread config eligibility", + buildCodexPluginThreadConfigEligibilityLogData({ + sessionId: attemptParams.sessionId, + sessionKey: attemptParams.sessionKey ?? "", + pluginThreadConfigRequired, + resolvedPluginPolicy, + enabledPluginConfigKeys, + pluginAppCacheKey, + startupAuthProfileId: params.startupAuthProfileId, + appServer: params.appServer, + }), + ); let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined; let startupSandboxEnvironmentAcquired = false; const releaseStartupSandboxEnvironment = async () => { @@ -291,8 +301,10 @@ export async function startCodexAttemptThread(params: { ); const startupExecutionCwd = resolveCodexAppServerExecutionCwd({ effectiveCwd: params.effectiveCwd, + localWorkspaceRoot: params.effectiveWorkspace, environment: startupSandboxEnvironment, nativeToolSurfaceEnabled: params.nativeToolSurfaceEnabled, + remoteWorkspaceRoot: params.appServer.remoteWorkspaceRoot, }); const startupSandboxPolicy = startupSandboxEnvironment ? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox) @@ -319,6 +331,7 @@ export async function startCodexAttemptThread(params: { mcpServersFingerprint: params.bundleMcpThreadConfig.fingerprint, mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated, environmentSelection: startupEnvironmentSelection, + appServerRuntimeFingerprint, contextEngineProjection: params.contextEngineProjection, signal, pluginThreadConfig: pluginThreadConfigRequired @@ -333,7 +346,7 @@ export async function startCodexAttemptThread(params: { activeStartupClient.request(method, requestParams, { timeoutMs: params.appServer.requestTimeoutMs, signal, - }), + }), appCache: defaultCodexAppInventoryCache, appCacheKey: pluginAppCacheKey, }), diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 3898ee9be723..016e06d6a3dd 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -103,6 +103,15 @@ export type CodexServerNotificationHandler = ( notification: CodexServerNotification, ) => Promise | void; +/** Runtime identity returned by the Codex app-server initialize handshake. */ +export type CodexAppServerRuntimeIdentity = { + serverVersion: string; + userAgent?: string; + codexHome?: string; + platformFamily?: string; + platformOs?: string; +}; + /** Stateful app-server JSON-RPC client over stdio or websocket transport. */ export class CodexAppServerClient { private readonly child: CodexAppServerTransport; @@ -117,6 +126,7 @@ export class CodexAppServerClient { private closed = false; private closeError: Error | undefined; private serverVersion: string | undefined; + private runtimeIdentity: CodexAppServerRuntimeIdentity | undefined; private stderrTail = ""; private pendingParse: | { @@ -193,6 +203,7 @@ export class CodexAppServerClient { }, } satisfies CodexInitializeParams); this.serverVersion = assertSupportedCodexAppServerVersion(response); + this.runtimeIdentity = buildCodexAppServerRuntimeIdentity(response, this.serverVersion); this.notify("initialized"); this.initialized = true; } @@ -202,6 +213,11 @@ export class CodexAppServerClient { return this.serverVersion; } + /** Returns runtime metadata detected during initialize. */ + getRuntimeIdentity(): CodexAppServerRuntimeIdentity | undefined { + return this.runtimeIdentity ? { ...this.runtimeIdentity } : undefined; + } + request( method: M, params: CodexAppServerRequestParams, @@ -623,6 +639,28 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse) return detectedVersion; } +function buildCodexAppServerRuntimeIdentity( + response: CodexInitializeResponse, + serverVersion: string, +): CodexAppServerRuntimeIdentity { + const userAgent = readNonEmptyInitializeString(response.userAgent); + const codexHome = readNonEmptyInitializeString(response.codexHome); + const platformFamily = readNonEmptyInitializeString(response.platformFamily); + const platformOs = readNonEmptyInitializeString(response.platformOs); + return { + serverVersion, + ...(userAgent ? { userAgent } : {}), + ...(codexHome ? { codexHome } : {}), + ...(platformFamily ? { platformFamily } : {}), + ...(platformOs ? { platformOs } : {}), + }; +} + +function readNonEmptyInitializeString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + /** Extracts the Codex version from the app-server initialize user-agent field. */ export function readCodexVersionFromUserAgent(userAgent: string | undefined): string | undefined { // Codex returns `/ ...`; the originator can be diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 4dcea915533f..a08a140bccbc 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -357,6 +357,34 @@ describe("Codex app-server config", () => { ).toStrictEqual({}); }); + it("rejects removed app-server topology fields", () => { + expect( + readCodexPluginConfig({ + appServer: { + transport: "websocket", + url: "wss://codex-app-server.example.internal/ws", + authToken: "capability-token", + connectionClass: "remote", + remoteAppsSubstrate: "preconfigured", + remoteWorkspace: { + localRoot: "/Users/kevinlin/code/openclaw", + remoteRoot: "/home/oai/openclaw-workspaces", + }, + }, + }), + ).toStrictEqual({}); + expect( + readCodexPluginConfig({ + appServer: { + remoteWorkspace: { + localRoot: "/Users/kevinlin/code/openclaw", + remoteRoot: "/home/oai/openclaw-workspaces", + }, + }, + }), + ).toStrictEqual({}); + }); + it("requires a websocket url when websocket transport is configured", () => { expect(() => resolveRuntimeForTest({ @@ -366,6 +394,55 @@ describe("Codex app-server config", () => { ).toThrow("appServer.url is required"); }); + it("marks authenticated non-loopback websocket app-servers as remote runtimes", () => { + const runtime = resolveRuntimeForTest({ + pluginConfig: { + appServer: { + transport: "websocket", + url: "wss://codex-app-server.example.internal/ws", + authToken: "capability-token", + remoteWorkspaceRoot: " /home/oai/openclaw-workspaces ", + }, + }, + }); + + expectFields(runtime, "runtime", { + connectionClass: "remote", + remoteAppsSubstrate: "preconfigured", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }); + }); + + it("treats IPv6 loopback websocket app-servers as local loopback", () => { + const runtime = resolveRuntimeForTest({ + pluginConfig: { + appServer: { + transport: "websocket", + url: "ws://[::1]:4242", + }, + }, + }); + + expectFields(runtime, "runtime", { + connectionClass: "local-loopback", + }); + }); + + it("rejects remote websocket app-servers without identity-bearing auth", () => { + expect(() => + resolveRuntimeForTest({ + pluginConfig: { + appServer: { + transport: "websocket", + url: "wss://codex-app-server.example.internal/ws", + }, + }, + }), + ).toThrow( + "remote Codex app-server WebSocket URLs require appServer.authToken or an Authorization header", + ); + }); + it("defaults native Codex approvals to unchained local execution", () => { const runtime = resolveRuntimeForTest({ pluginConfig: {}, diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 7c3bab11ce7e..6ffb54f41ab4 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -28,6 +28,8 @@ const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/; type CodexAppServerTransportMode = "stdio" | "websocket"; type CodexAppServerPolicyMode = "yolo" | "guardian"; +export type CodexAppServerConnectionClass = "local-loopback" | "remote"; +export type CodexAppServerRemoteAppsSubstrate = "preconfigured"; type OpenClawExecMode = "deny" | "allowlist" | "ask" | "auto" | "full"; type OpenClawExecSecurity = "deny" | "allowlist" | "full"; type OpenClawExecAsk = "off" | "on-miss" | "always"; @@ -170,6 +172,9 @@ export type CodexAppServerStartOptions = { export type CodexAppServerRuntimeOptions = { start: CodexAppServerStartOptions; + connectionClass: CodexAppServerConnectionClass; + remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate; + remoteWorkspaceRoot?: string; codeModeOnly: boolean; requestTimeoutMs: number; turnCompletionIdleTimeoutMs: number; @@ -209,6 +214,7 @@ export type CodexPluginConfig = { authToken?: string; headers?: Record; clearEnv?: string[]; + remoteWorkspaceRoot?: string; codeModeOnly?: boolean; requestTimeoutMs?: number; turnCompletionIdleTimeoutMs?: number; @@ -242,6 +248,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [ "authToken", "headers", "clearEnv", + "remoteWorkspaceRoot", "codeModeOnly", "requestTimeoutMs", "turnCompletionIdleTimeoutMs", @@ -309,6 +316,7 @@ const codexAppServerExperimentalSchema = z sandboxExecServer: z.boolean().optional(), }) .strict(); +const codexAppServerRemoteWorkspaceRootSchema = z.string().trim().min(1); const codexAppServerNetworkProxyDomainPermissionSchema = z.enum(["allow", "deny"]); const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "none"]); const codexAppServerNetworkProxySchema = z @@ -382,6 +390,7 @@ const codexPluginConfigSchema = z authToken: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), clearEnv: z.array(z.string()).optional(), + remoteWorkspaceRoot: codexAppServerRemoteWorkspaceRootSchema.optional(), codeModeOnly: z.boolean().optional(), requestTimeoutMs: z.number().positive().optional(), turnCompletionIdleTimeoutMs: z.number().positive().optional(), @@ -524,6 +533,9 @@ export function resolveCodexAppServerRuntimeOptions( const clearEnv = normalizeStringList(config.clearEnv); const authToken = readNonEmptyString(config.authToken); const url = readNonEmptyString(config.url); + const connectionClass = inferCodexAppServerConnectionClass({ transport, url }); + const remoteAppsSubstrate: CodexAppServerRemoteAppsSubstrate = "preconfigured"; + const remoteWorkspaceRoot = normalizeRemoteWorkspaceRoot(config.remoteWorkspaceRoot); const execMode = resolveEffectiveOpenClawExecModeForCodexAppServer({ execMode: params.execMode, execPolicy: params.execPolicy, @@ -616,6 +628,11 @@ export function resolveCodexAppServerRuntimeOptions( "plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket", ); } + assertCodexAppServerConnectionClassConfig({ + connectionClass, + authToken, + headers, + }); const configApprovalPolicy = resolveApprovalPolicy(config.approvalPolicy); const envApprovalPolicy = resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY); @@ -643,6 +660,9 @@ export function resolveCodexAppServerRuntimeOptions( headers, ...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}), }, + connectionClass, + remoteAppsSubstrate, + ...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}), codeModeOnly: config.codeModeOnly === true, requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000), turnCompletionIdleTimeoutMs: normalizePositiveNumber( @@ -1011,6 +1031,71 @@ function resolveTransport(value: unknown): CodexAppServerTransportMode { return value === "websocket" ? "websocket" : "stdio"; } +function normalizeRemoteWorkspaceRoot(value: string | undefined): string | undefined { + return readNonEmptyString(value); +} + +function inferCodexAppServerConnectionClass(params: { + transport: CodexAppServerTransportMode; + url?: string; +}): CodexAppServerConnectionClass { + if (params.transport !== "websocket") { + return "local-loopback"; + } + return params.url && isLoopbackWebSocketUrl(params.url) ? "local-loopback" : "remote"; +} + +function assertCodexAppServerConnectionClassConfig(params: { + connectionClass: CodexAppServerConnectionClass; + authToken?: string; + headers: Record; +}): void { + if ( + params.connectionClass === "remote" && + !hasIdentityBearingWebSocketAuth({ + authToken: params.authToken, + headers: params.headers, + }) + ) { + throw new Error( + "remote Codex app-server WebSocket URLs require appServer.authToken or an Authorization header", + ); + } +} + +function isLoopbackWebSocketUrl(value: string): boolean { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return false; + } + const host = parsed.hostname.toLowerCase(); + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +} + +function hasIdentityBearingWebSocketAuth(params: { + authToken?: string; + headers: Record; +}): boolean { + if (readNonEmptyString(params.authToken)) { + return true; + } + return Object.entries(params.headers).some( + ([key, value]) => + key.trim().toLowerCase() === "authorization" && Boolean(readNonEmptyString(value)), + ); +} + function resolvePolicyMode(value: unknown): CodexAppServerPolicyMode | undefined { return value === "guardian" || value === "yolo" ? value : undefined; } diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 7c09b7f41143..d08b31626d03 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -15,7 +15,9 @@ import { filterCodexDynamicToolsForAllowlist, hasWildcardCodexToolsAllow, includeForcedCodexDynamicToolAllow, + mapCodexAppServerRemoteWorkspacePath, resetOpenClawCodingToolsFactoryForTests, + resolveCodexAppServerExecutionCwd, resolveOpenClawCodingToolsSessionKeys, resolveCodexMessageToolProvider, setOpenClawCodingToolsFactoryForTests, @@ -26,6 +28,7 @@ import { filterCodexDynamicTools, resolveCodexDynamicToolsLoading, resolveCodexDynamicToolsLoadingForModel, + resolveCodexDynamicToolsLoadingForRuntime, shouldUseDirectCodexDynamicToolsForModel, } from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; @@ -145,6 +148,58 @@ describe("Codex app-server dynamic tool build", () => { ).toBe("discord"); }); + it("maps local gateway workspace suffixes to the remote Codex app-server root", () => { + expect( + mapCodexAppServerRemoteWorkspacePath({ + value: "/Users/kevinlin/code/openclaw/packages/example", + localWorkspaceRoot: "/Users/kevinlin/code/openclaw", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }), + ).toBe("/home/oai/openclaw-workspaces/packages/example"); + expect( + mapCodexAppServerRemoteWorkspacePath({ + value: "/Users/kevinlin/code/openclaw", + localWorkspaceRoot: "/Users/kevinlin/code/openclaw", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }), + ).toBe("/home/oai/openclaw-workspaces"); + }); + + it("fails closed when remote cwd projection cannot stay under the remote workspace root", () => { + expect(() => + mapCodexAppServerRemoteWorkspacePath({ + value: "/Users/kevinlin/code/other", + localWorkspaceRoot: "/Users/kevinlin/code/openclaw", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }), + ).toThrow("outside OpenClaw workspace root"); + }); + + it("maps Windows child paths through remote Codex app-server workspaces", () => { + expect( + mapCodexAppServerRemoteWorkspacePath({ + value: "C:\\Users\\kevinlin\\code\\openclaw\\packages\\example", + localWorkspaceRoot: "C:\\Users\\kevinlin\\code\\openclaw", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }), + ).toBe("/home/oai/openclaw-workspaces/packages/example"); + }); + + it("maps sandbox exec-server cwd through the remote workspace mapping", () => { + expect( + resolveCodexAppServerExecutionCwd({ + effectiveCwd: "/Users/kevinlin/code/openclaw", + environment: { + id: "sandbox-1", + cwd: "/Users/kevinlin/code/openclaw/sandbox", + } as never, + nativeToolSurfaceEnabled: true, + localWorkspaceRoot: "/Users/kevinlin/code/openclaw", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }), + ).toBe("/home/oai/openclaw-workspaces/sandbox"); + }); + it("filters Codex-native dynamic tools from app-server tool exposure", () => { const tools = [ "read", @@ -409,6 +464,27 @@ describe("Codex app-server dynamic tool build", () => { expect(webSearch).not.toHaveProperty("namespace"); }); + it("uses direct dynamic tools for remote Codex app-server connections", () => { + const tools = [createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search")]; + const loading = resolveCodexDynamicToolsLoadingForRuntime({}, "openai/gpt-5.5", { + connectionClass: "remote", + }); + const toolBridge = createCodexDynamicToolBridge({ + tools, + signal: new AbortController().signal, + loading, + }); + + expect(resolveCodexDynamicToolsLoadingForRuntime({}, "openai/gpt-5.5")).toBe("searchable"); + expect(loading).toBe("direct"); + expect(toolBridge.specs).toHaveLength(2); + expect(flattenCodexDynamicToolFunctions(toolBridge.specs).map((tool) => tool.name)).toEqual([ + "message", + "web_search", + ]); + expect(toolBridge.specs.some((tool) => tool.type === "namespace")).toBe(false); + }); + it("quarantines unreadable tool entries before Codex-specific filtering", async () => { const messageTool = createRuntimeDynamicTool("message"); const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], { diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 6ad35837e194..77044d671846 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -19,7 +19,10 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { isToolAllowed } from "openclaw/plugin-sdk/sandbox"; -import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js"; +import { + readCodexPluginConfig, + type CodexPluginConfig, +} from "./config.js"; import { filterCodexDynamicTools, isForcedPrivateQaCodexRuntime, @@ -585,12 +588,58 @@ export function resolveCodexSandboxEnvironmentSelection( /** Chooses the cwd visible to Codex native execution after sandbox exec-server setup. */ export function resolveCodexAppServerExecutionCwd(params: { effectiveCwd: string; + localWorkspaceRoot: string; environment?: CodexSandboxExecEnvironment; nativeToolSurfaceEnabled: boolean; + remoteWorkspaceRoot?: string; }): string { - return params.environment && params.nativeToolSurfaceEnabled + const cwd = params.environment && params.nativeToolSurfaceEnabled ? params.environment.cwd : params.effectiveCwd; + return mapCodexAppServerRemoteWorkspacePath({ + value: cwd, + localWorkspaceRoot: params.localWorkspaceRoot, + remoteWorkspaceRoot: params.remoteWorkspaceRoot, + }); +} + +/** Projects a local OpenClaw workspace cwd into the remote Codex app-server workspace root. */ +export function mapCodexAppServerRemoteWorkspacePath(params: { + value: string; + localWorkspaceRoot: string; + remoteWorkspaceRoot?: string; +}): string { + if (!params.remoteWorkspaceRoot) { + return params.value; + } + const localRoot = normalizeRemoteWorkspaceMatchPath(params.localWorkspaceRoot); + const remoteRoot = normalizeRemoteWorkspaceMatchPath(params.remoteWorkspaceRoot); + const normalizedValue = normalizeRemoteWorkspaceMatchPath(params.value); + if (!localRoot || !remoteRoot) { + throw new Error("Codex remoteWorkspaceRoot requires non-empty workspace roots."); + } + if (normalizedValue === localRoot) { + return remoteRoot; + } + const prefix = `${localRoot}/`; + if (!normalizedValue.startsWith(prefix)) { + throw new Error( + `Codex remoteWorkspaceRoot is configured but cwd ${params.value} is outside OpenClaw workspace root ${params.localWorkspaceRoot}; refusing to send a gateway-local cwd to the remote Codex app-server.`, + ); + } + return joinRemoteWorkspacePath(remoteRoot, normalizedValue.slice(prefix.length)); +} + +function normalizeRemoteWorkspaceMatchPath(value: string): string { + return trimTrailingPathSeparator(value.replace(/\\/gu, "/")); +} + +function trimTrailingPathSeparator(value: string): string { + return value.length > 1 ? value.replace(/[\\/]+$/u, "") : value; +} + +function joinRemoteWorkspacePath(remoteRoot: string, suffix: string): string { + return remoteRoot === "/" ? `/${suffix}` : `${remoteRoot}/${suffix}`; } /** Converts OpenClaw sandbox networking into Codex's external-sandbox policy shape. */ diff --git a/extensions/codex/src/app-server/dynamic-tool-profile.ts b/extensions/codex/src/app-server/dynamic-tool-profile.ts index 12158826a37b..9865ded59d78 100644 --- a/extensions/codex/src/app-server/dynamic-tool-profile.ts +++ b/extensions/codex/src/app-server/dynamic-tool-profile.ts @@ -1,7 +1,11 @@ /** * Dynamic tool profile rules for Codex app-server tool loading and filtering. */ -import type { CodexDynamicToolsLoading, CodexPluginConfig } from "./config.js"; +import type { + CodexAppServerConnectionClass, + CodexDynamicToolsLoading, + CodexPluginConfig, +} from "./config.js"; /** Tool names owned by Codex app-server and normally excluded from OpenClaw dynamic tools. */ export const CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES = [ @@ -84,6 +88,17 @@ export function resolveCodexDynamicToolsLoadingForModel( : loading; } +/** Resolves dynamic-tool loading for the app-server connection that will execute the turn. */ +export function resolveCodexDynamicToolsLoadingForRuntime( + config: Pick, + modelId: string | undefined, + options: { connectionClass?: CodexAppServerConnectionClass } = {}, + env: CodexDynamicToolProfileEnv = process.env, +): CodexDynamicToolsLoading { + const loading = resolveCodexDynamicToolsLoadingForModel(config, modelId, env); + return loading === "searchable" && options.connectionClass === "remote" ? "direct" : loading; +} + /** Filters OpenClaw tools that Codex owns natively or config explicitly excludes. */ export function filterCodexDynamicTools( tools: T[], diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts index 42b6dc4d2502..b678bd51391d 100644 --- a/extensions/codex/src/app-server/plugin-activation.ts +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -47,6 +47,7 @@ export type EnsureCodexPluginActivationParams = { appCache?: CodexAppInventoryCache; appCacheKey?: string; installEvenIfActive?: boolean; + targetAppIds?: readonly string[]; }; /** Diagnostics from refreshing Codex runtime surfaces after plugin activation. */ @@ -107,6 +108,7 @@ export async function ensureCodexPluginActivation( request: params.request, appCache: params.appCache, appCacheKey: params.appCacheKey, + targetAppIds: params.targetAppIds, }); refreshDiagnostics.push(...refreshResult.diagnostics); } catch (error) { @@ -145,6 +147,7 @@ export async function refreshCodexPluginRuntimeState(params: { request: CodexPluginRuntimeRequest; appCache?: CodexAppInventoryCache; appCacheKey?: string; + targetAppIds?: readonly string[]; }): Promise { const diagnostics: CodexPluginActivationDiagnostic[] = []; await params.request("plugin/list", { @@ -174,6 +177,7 @@ export async function refreshCodexPluginRuntimeState(params: { key: params.appCacheKey, request, forceRefetch: true, + targetAppIds: params.targetAppIds, }); } catch (error) { diagnostics.push({ @@ -274,13 +278,14 @@ function activationFailure( identity: ResolvedCodexPluginPolicy, reason: CodexPluginActivationReason, diagnostic: CodexPluginActivationDiagnostic, + extraDiagnostics: CodexPluginActivationDiagnostic[] = [], ): CodexPluginActivationResult { return { identity, ok: false, reason, installAttempted: false, - diagnostics: [diagnostic], + diagnostics: [diagnostic, ...extraDiagnostics], }; } diff --git a/extensions/codex/src/app-server/plugin-app-cache-key.test.ts b/extensions/codex/src/app-server/plugin-app-cache-key.test.ts index bb08674c3b93..3fbe66168880 100644 --- a/extensions/codex/src/app-server/plugin-app-cache-key.test.ts +++ b/extensions/codex/src/app-server/plugin-app-cache-key.test.ts @@ -1,6 +1,10 @@ // Codex tests cover plugin app cache key plugin behavior. import { describe, expect, it } from "vitest"; -import { resolveCodexPluginAppCacheEndpoint } from "./plugin-app-cache-key.js"; +import { + buildCodexAppServerRuntimeFingerprint, + buildCodexPluginAppCacheKey, + resolveCodexPluginAppCacheEndpoint, +} from "./plugin-app-cache-key.js"; describe("resolveCodexPluginAppCacheEndpoint", () => { it("keys plugin app inventory by websocket credentials without exposing them", () => { @@ -31,4 +35,85 @@ describe("resolveCodexPluginAppCacheEndpoint", () => { expect(second).not.toContain("token-second"); expect(second).not.toContain("Bearer second"); }); + + it("keys plugin app inventory by initialized remote runtime identity", () => { + const base = { + appServer: { + start: { + transport: "websocket" as const, + command: "codex", + args: [], + url: "wss://codex-app-server.example.internal/ws", + authToken: "secret-token", + headers: {}, + }, + }, + authProfileId: "profile-1", + }; + + const first = buildCodexPluginAppCacheKey({ + ...base, + runtimeIdentity: { + serverVersion: "0.20.0", + codexHome: "/home/oai/.codex", + platformFamily: "unix", + platformOs: "linux", + }, + }); + const second = buildCodexPluginAppCacheKey({ + ...base, + runtimeIdentity: { + serverVersion: "0.20.0", + codexHome: "/Users/kevinlin/.codex", + platformFamily: "unix", + platformOs: "macos", + }, + }); + + expect(first).not.toEqual(second); + expect(first).not.toContain("secret-token"); + expect(second).not.toContain("secret-token"); + }); + + it("fingerprints the remote app-server runtime used by thread bindings", () => { + const first = buildCodexAppServerRuntimeFingerprint({ + appServer: { + start: { + transport: "websocket", + command: "codex", + args: [], + url: "wss://codex-app-server.example.internal/ws", + authToken: "secret-token", + headers: {}, + }, + connectionClass: "remote", + remoteWorkspaceRoot: "/home/oai/openclaw-workspaces", + }, + runtimeIdentity: { + serverVersion: "0.20.0", + codexHome: "/home/oai/.codex", + }, + }); + const second = buildCodexAppServerRuntimeFingerprint({ + appServer: { + start: { + transport: "websocket", + command: "codex", + args: [], + url: "wss://codex-app-server.example.internal/ws", + authToken: "secret-token", + headers: {}, + }, + connectionClass: "remote", + }, + runtimeIdentity: { + serverVersion: "0.20.0", + codexHome: "/home/oai/.codex", + }, + }); + + expect(first).not.toEqual(second); + expect(first).not.toContain("secret-token"); + expect(second).not.toContain("secret-token"); + }); }); diff --git a/extensions/codex/src/app-server/plugin-app-cache-key.ts b/extensions/codex/src/app-server/plugin-app-cache-key.ts index 261d04de0aaf..cfe2f2e0e0c3 100644 --- a/extensions/codex/src/app-server/plugin-app-cache-key.ts +++ b/extensions/codex/src/app-server/plugin-app-cache-key.ts @@ -9,6 +9,7 @@ import { } from "./app-inventory-cache.js"; import { resolveCodexAppServerHomeDir } from "./auth-bridge.js"; import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js"; +import type { CodexAppServerRuntimeIdentity } from "./client.js"; /** Inputs that identify the Codex app inventory cache scope for one runtime. */ export type CodexPluginAppCacheKeyParams = Omit< @@ -17,17 +18,39 @@ export type CodexPluginAppCacheKeyParams = Omit< > & { appServer: Pick; agentDir?: string; + runtimeIdentity?: CodexAppServerRuntimeIdentity; }; /** Builds the full app inventory cache key for Codex plugin/app discovery. */ export function buildCodexPluginAppCacheKey(params: CodexPluginAppCacheKeyParams): string { return buildCodexAppInventoryCacheKey({ - codexHome: resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir), + codexHome: + params.runtimeIdentity?.codexHome ?? + resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir), endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer), authProfileId: params.authProfileId, accountId: params.accountId, envApiKeyFingerprint: params.envApiKeyFingerprint, - appServerVersion: params.appServerVersion, + appServerVersion: params.appServerVersion ?? params.runtimeIdentity?.serverVersion, + runtimeIdentity: params.runtimeIdentity, + }); +} + +/** Builds a durable thread-binding fingerprint for one initialized app-server runtime. */ +export function buildCodexAppServerRuntimeFingerprint(params: { + appServer: Pick< + CodexAppServerRuntimeOptions, + "start" | "connectionClass" | "remoteWorkspaceRoot" + >; + appServerVersion?: string; + runtimeIdentity?: CodexAppServerRuntimeIdentity; +}): string { + return JSON.stringify({ + endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer), + connectionClass: params.appServer.connectionClass, + remoteWorkspaceRoot: params.appServer.remoteWorkspaceRoot ?? null, + appServerVersion: params.appServerVersion ?? params.runtimeIdentity?.serverVersion ?? null, + runtimeIdentity: params.runtimeIdentity ?? null, }); } diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index 1a7704663ae9..e5431ed50047 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -567,6 +567,67 @@ describe("Codex plugin thread config", () => { ]); }); + it("installs an unconfigured remote plugin before waiting for app inventory", async () => { + const appCache = new CodexAppInventoryCache(); + let installed = false; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed, enabled: installed }), + ]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + if (method === "plugin/install") { + installed = true; + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + if (method === "app/list") { + return { + data: [appInfo("google-calendar-app", true, installed)], + nextCursor: null, + } satisfies v2.AppsListResponse; + } + throw new Error(`unexpected request ${method}: ${JSON.stringify(params)}`); + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + request, + }); + + expect(config.configPatch?.apps).toMatchObject({ + "google-calendar-app": { + enabled: true, + }, + }); + const methods = request.mock.calls.map(([method]) => method); + expect(methods.indexOf("plugin/install")).toBeGreaterThan(-1); + expect(methods.indexOf("app/list")).toBeGreaterThan(methods.indexOf("plugin/install")); + }); + it("surfaces critical post-install refresh failures and keeps plugin apps disabled", async () => { const appCache = new CodexAppInventoryCache(); await appCache.refreshNow({ @@ -695,7 +756,6 @@ describe("Codex plugin thread config", () => { pluginConfig: { codexPlugins: { enabled: true } }, appCacheKey: "runtime-b", }); - expect(second).toBe(first); expect(third).not.toBe(second); }); diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 34fb5fce6eff..a3a6e815512a 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -77,7 +77,7 @@ export type BuildCodexPluginThreadConfigParams = { nowMs?: number; }; -const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 1; +const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 2; const CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION = 1; /** Returns true when plugin config exists and thread config may need app patches. */ @@ -129,6 +129,7 @@ export async function buildCodexPluginThreadConfig( await refreshAppInventoryNow(params, appCache, { forceRefetch: true, reason: "initial_missing", + targetAppIds: collectInventoryOwnedAppIds(inventory), }); inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, @@ -154,6 +155,7 @@ export async function buildCodexPluginThreadConfig( request: params.request, appCache, appCacheKey: params.appCacheKey, + targetAppIds: record.ownedAppIds, }); activationResults.push(activation); if (!activation.ok) { @@ -168,6 +170,7 @@ export async function buildCodexPluginThreadConfig( await refreshAppInventoryNow(params, appCache, { forceRefetch: true, reason: "post_install", + targetAppIds: collectInventoryOwnedAppIds(inventory), }); inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, @@ -186,6 +189,7 @@ export async function buildCodexPluginThreadConfig( await refreshAppInventoryNow(params, appCache, { forceRefetch: true, reason: "not_ready_plugin_apps", + targetAppIds: collectInventoryOwnedAppIds(inventory), }); inventory = await readCodexPluginInventory({ pluginConfig: params.pluginConfig, @@ -358,6 +362,9 @@ function shouldWaitForInitialAppInventory( policy: ResolvedCodexPluginsPolicy, inventory: CodexPluginInventory, ): boolean { + if (inventory.records.some((record) => record.activationRequired)) { + return false; + } return Boolean( params.appCacheKey && policy.pluginPolicies.some((plugin) => plugin.enabled) && @@ -368,7 +375,7 @@ function shouldWaitForInitialAppInventory( async function refreshAppInventoryNow( params: BuildCodexPluginThreadConfigParams, appCache: CodexAppInventoryCache, - options: { forceRefetch?: boolean; reason?: string } = {}, + options: { forceRefetch?: boolean; reason?: string; targetAppIds?: readonly string[] } = {}, ): Promise { const appCacheKey = params.appCacheKey; if (!appCacheKey) { @@ -382,6 +389,7 @@ async function refreshAppInventoryNow( request, nowMs: params.nowMs, forceRefetch: options.forceRefetch, + targetAppIds: options.targetAppIds, }); return snapshot; } catch (error) { @@ -395,6 +403,12 @@ async function refreshAppInventoryNow( } } +function collectInventoryOwnedAppIds(inventory: CodexPluginInventory): string[] { + return Array.from( + new Set(inventory.records.flatMap((record) => record.ownedAppIds).filter(Boolean)), + ).toSorted(); +} + function resolveThreadConfigAppsForRecord(params: { record: CodexPluginInventoryRecord; inventory: CodexPluginInventory; diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index 8fb4ed7def81..d4af09ac5156 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -48,6 +48,9 @@ export type CodexInitializeResponse = { }; protocolVersion?: string; userAgent?: string; + codexHome?: string; + platformFamily?: string; + platformOs?: string; }; export type CodexUserInput = diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 67551666f92a..671209363dfc 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -156,6 +156,8 @@ function createThreadLifecycleAppServerOptions(): Parameters< approvalsReviewer: "user", sandbox: "workspace-write", codeModeOnly: false, + connectionClass: "local-loopback", + remoteAppsSubstrate: "preconfigured", }; } diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index c315b0e5b6a1..d92583d1c23f 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -178,6 +178,7 @@ import { import { filterCodexDynamicTools, resolveCodexDynamicToolsLoadingForModel, + resolveCodexDynamicToolsLoadingForRuntime, } from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js"; @@ -789,7 +790,9 @@ export async function runCodexAppServerAttempt( tools, registeredTools, signal: runAbortController.signal, - loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId), + loading: resolveCodexDynamicToolsLoadingForRuntime(pluginConfig, params.modelId, { + connectionClass: appServer.connectionClass, + }), directToolNames: resolveCodexDynamicToolDirectNames(params), hookContext: { agentId: sessionAgentId, diff --git a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts index aef165f75c70..7a4920071d23 100644 --- a/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/schema-normalization-runtime-contract.test.ts @@ -49,6 +49,8 @@ function createAppServerOptions(): Parameters[0]["ap approvalPolicy: "never", approvalsReviewer: "user", sandbox: "workspace-write", + connectionClass: "local-loopback", + remoteAppsSubstrate: "preconfigured", }; } diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 3b2b7003160b..af0ba3d55e49 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -66,6 +66,7 @@ describe("codex app-server session binding", () => { webSearchThreadConfigFingerprint: "web-search-v1", userMcpServersFingerprint: "user-mcp-v1", nativeHookRelayGeneration: "generation-v1", + appServerRuntimeFingerprint: "remote-runtime-v1", }); const binding = await readCodexAppServerBinding(sessionFile); @@ -82,6 +83,7 @@ describe("codex app-server session binding", () => { expect(binding?.webSearchThreadConfigFingerprint).toBe("web-search-v1"); expect(binding?.userMcpServersFingerprint).toBe("user-mcp-v1"); expect(binding?.nativeHookRelayGeneration).toBe("generation-v1"); + expect(binding?.appServerRuntimeFingerprint).toBe("remote-runtime-v1"); const bindingStat = await fs.stat(resolveCodexAppServerBindingPath(sessionFile)); expect(bindingStat.isFile()).toBe(true); }); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index ddd9bec6eb45..3534dc94e022 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -74,6 +74,7 @@ export type CodexAppServerThreadBinding = { userMcpServersFingerprint?: string; mcpServersFingerprint?: string; nativeHookRelayGeneration?: string; + appServerRuntimeFingerprint?: string; pluginAppsFingerprint?: string; pluginAppsInputFingerprint?: string; pluginAppPolicyContext?: PluginAppPolicyContext; @@ -214,6 +215,11 @@ export async function readCodexAppServerBinding( parsed.nativeHookRelayGeneration.trim() ? parsed.nativeHookRelayGeneration : undefined, + appServerRuntimeFingerprint: + typeof parsed.appServerRuntimeFingerprint === "string" && + parsed.appServerRuntimeFingerprint.trim() + ? parsed.appServerRuntimeFingerprint + : undefined, pluginAppsFingerprint: typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined, pluginAppsInputFingerprint: @@ -274,6 +280,7 @@ export async function writeCodexAppServerBinding( userMcpServersFingerprint: binding.userMcpServersFingerprint, mcpServersFingerprint: binding.mcpServersFingerprint, nativeHookRelayGeneration: binding.nativeHookRelayGeneration, + appServerRuntimeFingerprint: binding.appServerRuntimeFingerprint, pluginAppsFingerprint: binding.pluginAppsFingerprint, pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, pluginAppPolicyContext: binding.pluginAppPolicyContext, diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index d3b4c8129302..089c60219be5 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -13,7 +13,10 @@ import { writeCodexAppServerBinding as writeRawCodexAppServerBinding, } from "./session-binding.js"; import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js"; -import { startOrResumeThread } from "./thread-lifecycle.js"; +import { + shouldRotateCodexAppServerBindingForRuntime, + startOrResumeThread, +} from "./thread-lifecycle.js"; function createThreadLifecycleAppServerOptions(): Parameters< typeof startOrResumeThread @@ -31,6 +34,8 @@ function createThreadLifecycleAppServerOptions(): Parameters< approvalsReviewer: "user", sandbox: "workspace-write", codeModeOnly: false, + connectionClass: "local-loopback", + remoteAppsSubstrate: "preconfigured", }; } @@ -249,6 +254,35 @@ function createTwoCalendarAppPolicyContext() { setupRunAttemptTestHooks(); describe("Codex app-server thread lifecycle bindings", () => { + it("rotates remote runtime bindings when the app-server fingerprint is missing or changed", () => { + expect( + shouldRotateCodexAppServerBindingForRuntime({ + connectionClass: "remote", + current: "remote-runtime-v1", + }), + ).toBe(true); + expect( + shouldRotateCodexAppServerBindingForRuntime({ + connectionClass: "remote", + current: "remote-runtime-v1", + binding: "remote-runtime-v0", + }), + ).toBe(true); + expect( + shouldRotateCodexAppServerBindingForRuntime({ + connectionClass: "remote", + current: "remote-runtime-v1", + binding: "remote-runtime-v1", + }), + ).toBe(false); + expect( + shouldRotateCodexAppServerBindingForRuntime({ + connectionClass: "local-loopback", + current: "local-runtime-v1", + }), + ).toBe(false); + }); + it("does not write a binding when thread start resolves after abort", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -2239,6 +2273,8 @@ describe("Codex app-server thread lifecycle bindings", () => { approvalPolicy: "never", approvalsReviewer: "user", sandbox: "workspace-write", + connectionClass: "local-loopback", + remoteAppsSubstrate: "preconfigured", }, }); diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 6de519037318..3fce114ba09b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -159,6 +159,8 @@ function createThreadLifecycleAppServerOptions(): Parameters< approvalPolicy: "never", approvalsReviewer: "user", sandbox: "workspace-write", + connectionClass: "local-loopback", + remoteAppsSubstrate: "preconfigured", }; } @@ -830,6 +832,8 @@ describe("Codex app-server turn params", () => { approvalPolicy: "on-request" as const, approvalsReviewer: "guardian_subagent" as const, sandbox: "danger-full-access" as const, + connectionClass: "local-loopback" as const, + remoteAppsSubstrate: "preconfigured" as const, serviceTier: "flex" as const, }; diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 8f7608806684..53c93da9933c 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -311,6 +311,7 @@ export async function startOrResumeThread(params: { mcpServersFingerprint?: string; mcpServersFingerprintEvaluated?: boolean; environmentSelection?: CodexTurnEnvironmentParams[]; + appServerRuntimeFingerprint?: string; pluginThreadConfig?: CodexPluginThreadConfigProvider; contextEngineProjection?: CodexContextEngineThreadBootstrapProjection; signal?: AbortSignal; @@ -359,6 +360,21 @@ export async function startOrResumeThread(params: { config: params.params.config, }), ); + if ( + binding?.threadId && + shouldRotateCodexAppServerBindingForRuntime({ + connectionClass: params.appServer.connectionClass, + current: params.appServerRuntimeFingerprint, + binding: binding.appServerRuntimeFingerprint, + }) + ) { + embeddedAgentLog.debug("codex app-server runtime identity changed; starting a new thread", { + threadId: binding.threadId, + connectionClass: params.appServer.connectionClass, + }); + await clearCodexAppServerBinding(params.params.sessionFile); + binding = undefined; + } const startModelSelection = resolveCodexAppServerThreadModelSelection({ provider: params.params.provider, model: params.params.modelId, @@ -673,6 +689,7 @@ export async function startOrResumeThread(params: { nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration, + appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint, pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint, pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext, @@ -723,6 +740,7 @@ export async function startOrResumeThread(params: { networkProxyConfigFingerprint, nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration ?? resumeBinding.nativeHookRelayGeneration, + appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, pluginAppsFingerprint: resumeBinding.pluginAppsFingerprint, pluginAppsInputFingerprint: resumeBinding.pluginAppsInputFingerprint, pluginAppPolicyContext: resumeBinding.pluginAppPolicyContext, @@ -824,6 +842,7 @@ export async function startOrResumeThread(params: { networkProxyProfileName: params.appServer.networkProxy?.profileName, networkProxyConfigFingerprint, nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, + appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, pluginAppsFingerprint: pluginThreadConfig?.fingerprint, pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, pluginAppPolicyContext: pluginThreadConfig?.policyContext, @@ -874,6 +893,7 @@ export async function startOrResumeThread(params: { networkProxyProfileName: params.appServer.networkProxy?.profileName, networkProxyConfigFingerprint, nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, + appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, pluginAppsFingerprint: pluginThreadConfig?.fingerprint, pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, pluginAppPolicyContext: pluginThreadConfig?.policyContext, @@ -888,6 +908,20 @@ export async function startOrResumeThread(params: { }; } +export function shouldRotateCodexAppServerBindingForRuntime(params: { + connectionClass: CodexAppServerRuntimeOptions["connectionClass"]; + current?: string; + binding?: string; +}): boolean { + if (!params.current) { + return false; + } + if (params.binding === params.current) { + return false; + } + return params.connectionClass === "remote" || Boolean(params.binding); +} + function isTransientWebSearchRestriction( params: Pick< Parameters[0],