feat(codex): support remote app-server plugins (#94203)

* feat(codex): support remote app-server plugins

* fix(codex): refresh remote plugin apps after install

* fix(codex): infer remote app-server topology

* docs(codex): document remote app-server plugin config

* fix(codex): remove remote mutation policy

* fix(codex): simplify remote workspace root

* docs(codex): fix remote app-server PR docs
This commit is contained in:
Kevin Lin
2026-06-17 13:52:34 -07:00
committed by GitHub
parent 20534c57b7
commit bc5081c587
27 changed files with 801 additions and 83 deletions
+29 -20
View File
@@ -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.<profile>.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.<profile>.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:
+24 -20
View File
@@ -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.<profile>.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.<profile>.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
+8
View File
@@ -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.",
@@ -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 () => {
@@ -25,6 +25,7 @@ export type CodexAppInventoryRequest = (
export type CodexAppInventoryCacheKeyInput = {
codexHome?: string;
endpoint?: string;
runtimeIdentity?: Record<string, string | undefined>;
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<CodexAppInventorySnapshot> {
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<string, string | undefined> | undefined,
): Record<string, string> | 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<v2.AppInfo[]> {
const apps: v2.AppInfo[] = [];
const targetIds = new Set(targetAppIds.filter(Boolean));
const foundTargetIds = new Set<string>();
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;
}
@@ -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({
@@ -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,
}),
+38
View File
@@ -103,6 +103,15 @@ export type CodexServerNotificationHandler = (
notification: CodexServerNotification,
) => Promise<void> | 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<M extends CodexAppServerRequestMethod>(
method: M,
params: CodexAppServerRequestParams<M>,
@@ -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 `<originator>/<codex-version> ...`; the originator can be
@@ -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: {},
+85
View File
@@ -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<string, string>;
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<string, string>;
}): 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<string, string>;
}): 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;
}
@@ -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[], {
@@ -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. */
@@ -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<CodexPluginConfig, "codexDynamicToolsLoading">,
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<T extends { name: string }>(
tools: T[],
@@ -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<CodexPluginRuntimeRefreshResult> {
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],
};
}
@@ -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");
});
});
@@ -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<CodexAppServerRuntimeOptions, "start">;
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,
});
}
@@ -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);
});
@@ -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<CodexAppInventorySnapshot | undefined> {
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;
@@ -48,6 +48,9 @@ export type CodexInitializeResponse = {
};
protocolVersion?: string;
userAgent?: string;
codexHome?: string;
platformFamily?: string;
platformOs?: string;
};
export type CodexUserInput =
@@ -156,6 +156,8 @@ function createThreadLifecycleAppServerOptions(): Parameters<
approvalsReviewer: "user",
sandbox: "workspace-write",
codeModeOnly: false,
connectionClass: "local-loopback",
remoteAppsSubstrate: "preconfigured",
};
}
@@ -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,
@@ -49,6 +49,8 @@ function createAppServerOptions(): Parameters<typeof startOrResumeThread>[0]["ap
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
connectionClass: "local-loopback",
remoteAppsSubstrate: "preconfigured",
};
}
@@ -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);
});
@@ -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,
@@ -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",
},
});
@@ -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,
};
@@ -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<typeof startOrResumeThread>[0],