diff --git a/docs/docs_map.md b/docs/docs_map.md index 7d2c530f98e..e8bae29298c 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5424,6 +5424,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: How native plugin setup works - H2: V1 support boundary - H2: App inventory and ownership + - H2: Connected account apps - H2: Thread app config - H2: Destructive action policy - H2: Troubleshooting diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index e491d2eeeeb..4f5f36c8c39 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -286,7 +286,8 @@ conversation bindings, or any non-Codex harness. config: { codexPlugins: { enabled: true, - allow_destructive_actions: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", plugins: { "google-calendar": { enabled: true, @@ -305,6 +306,9 @@ conversation bindings, or any non-Codex harness. - `plugins.entries.codex.config.codexPlugins.enabled`: enables native Codex plugin/app support for the Codex harness. Default: `false`. +- `plugins.entries.codex.config.codexPlugins.allow_all_plugins`: exposes every + currently accessible app connected to the authenticated Codex account in + each new native Codex thread. Default: `false`. - `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`: default destructive-action policy for migrated plugin app elicitations. Use `true` to accept safe Codex approval schemas without prompting, `false` @@ -341,6 +345,13 @@ asynchronously when stale. Codex thread app config is computed at Codex harness session establishment, not on every turn; use `/new`, `/reset`, or a gateway restart after changing native plugin config. +`codexPlugins.allow_all_plugins` snapshots every currently accessible account +app into each new native Codex thread. It does not install plugins or apps, and +inaccessible apps stay excluded. Account apps use the global +`codexPlugins.allow_destructive_actions` policy. Explicit plugin entries take +precedence when the same app is present in both paths. If `app/list` cannot be +read, account-wide exposure fails closed. + - `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings. - `apiKey`: Optional Firecrawl API key for higher limits (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var. - `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`; self-hosted overrides must target private/internal endpoints). diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 4ac7cc28628..9dbe71a57b4 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -38,14 +38,14 @@ All Codex harness settings live under `plugins.entries.codex.config`. Top-level fields: -| Field | Default | Meaning | -| -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. | -| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. | -| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | -| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | -| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. See [Native Codex plugins](/plugins/codex-native-plugins). | -| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | +| Field | Default | Meaning | +| -------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. | +| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. | +| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | +| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | +| `codexPlugins` | disabled | Native Codex plugin/app support, including opt-in access to connected account apps. See [Native Codex plugins](/plugins/codex-native-plugins). | +| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | ## App-server transport diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index f25d34e5722..24442e17107 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -188,11 +188,46 @@ plugin through stable ownership: an exact app id from plugin detail, a known MCP server name, or unique stable metadata. Display-name-only or ambiguous ownership is excluded until the next inventory refresh proves ownership. +## Connected account apps + +Owner-operated agents can opt into every app already connected to their Codex +account without requiring a matching plugin package: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", + }, + }, + }, + }, + }, +} +``` + +`allow_all_plugins: true` takes a complete `app/list` snapshot when a new native +Codex thread is established and admits only apps marked accessible for that +account. It does not install, authenticate, or enable apps globally. Existing +threads keep their persisted app set; use `/new`, `/reset`, or restart the +gateway to pick up newly connected or revoked apps. + +Account apps inherit the global `codexPlugins.allow_destructive_actions` value, +which accepts `true`, `false`, `"auto"`, or `"ask"`. Explicit per-plugin policy +overrides the global policy for overlapping app ids. Inventory failures fail +closed instead of falling back to an unrestricted default. + ## Thread app config OpenClaw injects a restrictive `config.apps` patch for the Codex thread: -`_default` is disabled, and only apps owned by enabled migrated plugins are -enabled. +`_default` is disabled, and only apps owned by enabled migrated plugins or +accessible account apps admitted by `allow_all_plugins` are enabled. `destructive_enabled` on each app comes from the effective global or per-plugin `allow_destructive_actions` policy; `true`, `"auto"`, and `"ask"` @@ -203,7 +238,7 @@ get `open_world_enabled: true`. OpenClaw does not expose a separate plugin-level open-world policy knob and does not maintain per-plugin destructive tool-name deny lists. -Tool approval mode defaults to automatic for plugin apps, so non-destructive +Tool approval mode defaults to automatic for admitted apps, so non-destructive read tools run without a same-thread approval prompt. Destructive tools stay controlled by each app's `destructive_enabled` policy. diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index b2ec27107cd..6b5b9d54b0f 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -101,6 +101,10 @@ "type": "boolean", "default": false }, + "allow_all_plugins": { + "type": "boolean", + "default": false + }, "allow_destructive_actions": { "oneOf": [{ "type": "boolean" }, { "const": "auto" }, { "const": "ask" }], "default": true @@ -347,6 +351,11 @@ "help": "Expose explicit migrated Codex plugin entries to Codex harness turns.", "advanced": true }, + "codexPlugins.allow_all_plugins": { + "label": "Allow All Connected Apps", + "help": "Expose every currently accessible app connected to the authenticated Codex account when a new Codex thread starts.", + "advanced": true + }, "codexPlugins.allow_destructive_actions": { "label": "Allow Destructive Plugin Actions", "help": "Default policy for plugin app write or destructive action elicitations. Use true to accept safe schemas without prompting, false to decline, auto to ask through plugin approvals when Codex requires approval, or ask to prompt for every write/destructive action without durable approval.", diff --git a/extensions/codex/src/app-server/attempt-diagnostics.test.ts b/extensions/codex/src/app-server/attempt-diagnostics.test.ts index 300c92509f9..18e35479681 100644 --- a/extensions/codex/src/app-server/attempt-diagnostics.test.ts +++ b/extensions/codex/src/app-server/attempt-diagnostics.test.ts @@ -36,6 +36,7 @@ describe("Codex app-server attempt diagnostics", () => { const resolvedPluginPolicy = resolveCodexPluginsPolicy({ codexPlugins: { enabled: true, + allow_all_plugins: true, plugins: { "google-calendar": { marketplaceName: "openai-curated", @@ -69,6 +70,7 @@ describe("Codex app-server attempt diagnostics", () => { enabled: true, policyConfigured: true, policyEnabled: true, + allowAllPlugins: true, pluginConfigKeys: ["google-calendar"], enabledPluginConfigKeys: ["google-calendar"], appCacheKeyFingerprint: expect.stringMatching(/^sha256:/), diff --git a/extensions/codex/src/app-server/attempt-diagnostics.ts b/extensions/codex/src/app-server/attempt-diagnostics.ts index 3355241d190..3fde1a37672 100644 --- a/extensions/codex/src/app-server/attempt-diagnostics.ts +++ b/extensions/codex/src/app-server/attempt-diagnostics.ts @@ -7,7 +7,10 @@ import { emitTrustedDiagnosticEventWithPrivateData, type DiagnosticModelCallContent, } from "openclaw/plugin-sdk/diagnostic-runtime"; -import type { CodexAppServerRuntimeOptions, resolveCodexPluginsPolicy } from "./config.js"; +import type { + CodexAppServerRuntimeOptions, + resolveCodexPluginsPolicy, +} from "./config.js"; type TrustedDiagnosticEventInput = Parameters[0]; @@ -73,6 +76,7 @@ export function buildCodexPluginThreadConfigEligibilityLogData(params: { enabled: params.pluginThreadConfigRequired, policyConfigured: params.resolvedPluginPolicy?.configured === true, policyEnabled: params.resolvedPluginPolicy?.enabled === true, + allowAllPlugins: params.resolvedPluginPolicy?.allowAllPlugins === true, pluginConfigKeys: params.resolvedPluginPolicy?.pluginPolicies .map((plugin) => plugin.configKey) .toSorted(), diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 193a100de1d..ff3a0fae2be 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -155,7 +155,8 @@ export async function startCodexAttemptThread(params: { : undefined; const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled; const mcpElicitationDelegationRequired = - resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired; + resolvedPluginPolicy?.enabled === true || + computerUseMcpElicitationDelegationRequired; const enabledPluginConfigKeys = resolvedPluginPolicy ? resolvedPluginPolicy.pluginPolicies .filter((plugin) => plugin.enabled) diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 9ac4d4b257e..9533a93f940 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -1162,6 +1162,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(policy).toEqual({ configured: true, enabled: true, + allowAllPlugins: false, allowDestructiveActions: false, destructiveApprovalMode: "deny", pluginPolicies: [ @@ -1213,6 +1214,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(resolveCodexPluginsPolicy(config)).toEqual({ configured: true, enabled: true, + allowAllPlugins: false, allowDestructiveActions: true, destructiveApprovalMode: "auto", pluginPolicies: [ @@ -1269,6 +1271,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(resolveCodexPluginsPolicy(config)).toEqual({ configured: true, enabled: true, + allowAllPlugins: false, allowDestructiveActions: true, destructiveApprovalMode: "ask", pluginPolicies: [ @@ -1325,6 +1328,7 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(policy).toEqual({ configured: true, enabled: true, + allowAllPlugins: false, allowDestructiveActions: true, destructiveApprovalMode: "allow", pluginPolicies: [ @@ -1357,6 +1361,44 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([]); }); + it("parses opt-in access to all connected account apps", () => { + const config = readCodexPluginConfig({ + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", + }, + }); + + expect(config.codexPlugins).toEqual({ + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", + }); + expect(resolveCodexPluginsPolicy(config)).toEqual({ + configured: true, + enabled: true, + allowAllPlugins: true, + allowDestructiveActions: true, + destructiveApprovalMode: "auto", + pluginPolicies: [], + }); + }); + + it("requires native plugin support before exposing all connected account apps", () => { + expect(resolveCodexPluginsPolicy({ codexPlugins: { allow_all_plugins: true } })).toEqual({ + configured: true, + enabled: false, + allowAllPlugins: false, + allowDestructiveActions: true, + destructiveApprovalMode: "allow", + pluginPolicies: [], + }); + expect( + readCodexPluginConfig({ codexPlugins: { enabled: true, allow_all_plugins: "yes" } }), + ).toEqual({}); + }); + it("treats configured and environment commands as explicit overrides", () => { expectFields( resolveRuntimeForTest({ diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 52de5ddaf02..235cd6ffe42 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -111,6 +111,7 @@ export type CodexPluginEntryConfig = { export type CodexPluginsConfig = { enabled?: boolean; + allow_all_plugins?: boolean; allow_destructive_actions?: CodexPluginDestructivePolicy; plugins?: Record; }; @@ -159,6 +160,7 @@ export type ResolvedCodexPluginPolicy = { export type ResolvedCodexPluginsPolicy = { configured: boolean; enabled: boolean; + allowAllPlugins: boolean; allowDestructiveActions: boolean; destructiveApprovalMode: CodexPluginDestructiveApprovalMode; pluginPolicies: ResolvedCodexPluginPolicy[]; @@ -288,6 +290,7 @@ export const CODEX_COMPUTER_USE_CONFIG_KEYS = [ export const CODEX_PLUGINS_CONFIG_KEYS = [ "enabled", + "allow_all_plugins", "allow_destructive_actions", "plugins", ] as const; @@ -369,6 +372,7 @@ const codexPluginEntryConfigSchema = z const codexPluginsConfigSchema = z .object({ enabled: z.boolean().optional(), + allow_all_plugins: z.boolean().optional(), allow_destructive_actions: codexPluginDestructivePolicySchema.optional(), plugins: z.record(z.string(), codexPluginEntryConfigSchema).optional(), }) @@ -496,6 +500,7 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex return { configured, enabled, + allowAllPlugins: enabled && config?.allow_all_plugins === true, allowDestructiveActions: destructivePolicy.allowDestructiveActions, destructiveApprovalMode: destructivePolicy.destructiveApprovalMode, pluginPolicies, 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 44575eb1087..9ecc349c22f 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { addSandboxShellDynamicToolsIfAvailable, buildDynamicTools, + disableCodexPluginThreadConfig, filterCodexDynamicToolsForAllowlist, hasWildcardCodexToolsAllow, includeForcedCodexDynamicToolAllow, @@ -128,6 +129,24 @@ async function buildDynamicToolsForTest( } describe("Codex app-server dynamic tool build", () => { + it("removes account-wide app access when native tools are restricted", () => { + expect( + disableCodexPluginThreadConfig({ + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", + }, + }), + ).toEqual({ + codexPlugins: { + enabled: false, + allow_all_plugins: true, + allow_destructive_actions: "auto", + }, + }); + }); + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-tools-")); }); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 37396473371..78fb214a3ec 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -663,7 +663,7 @@ function codexNetworkAccessForOpenClawSandbox( return Boolean(network && network !== "none"); } -/** Returns a Codex config copy with app-server Codex plugin loading disabled for thread tools. */ +/** Returns a Codex config copy with all app exposure disabled for restricted thread tools. */ export function disableCodexPluginThreadConfig(pluginConfig?: unknown): CodexPluginConfig { const config = readCodexPluginConfig(pluginConfig); return { diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index 759e68b08db..06470222215 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -191,6 +191,29 @@ function createPluginAppPolicyContext( }; } +function createAccountAppPolicyContext(params: { + appId: string; + appName: string; + allowDestructiveActions: boolean; + destructiveApprovalMode?: "allow" | "deny" | "auto" | "ask"; +}) { + return { + fingerprint: "account-app-policy-1", + apps: { + [params.appId]: { + source: "account" as const, + appName: params.appName, + allowDestructiveActions: params.allowDestructiveActions, + ...(params.destructiveApprovalMode + ? { destructiveApprovalMode: params.destructiveApprovalMode } + : {}), + mcpServerNames: [], + }, + }, + pluginAppIds: {}, + }; +} + function appsForPlugin( apps: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>, pluginName: string, @@ -835,6 +858,61 @@ describe("Codex app-server elicitation bridge", () => { expect(mockCallGatewayTool).not.toHaveBeenCalled(); }); + it("routes approvals for account-connected apps through the configured policy", async () => { + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-meetings", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-meetings", decision: "allow-once" }); + + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildConnectorPluginApprovalElicitation({ + message: "Allow ChatGPT Meetings to import a meeting?", + _meta: { + codex_approval_kind: "mcp_tool_call", + source: "connector", + connector_id: "chatgpt_meetings", + connector_name: "ChatGPT Meetings", + tool_title: "import_meeting", + }, + }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createAccountAppPolicyContext({ + appId: "chatgpt_meetings", + appName: "ChatGPT Meetings", + allowDestructiveActions: true, + destructiveApprovalMode: "auto", + }), + }); + + expect(result).toEqual({ action: "accept", content: null, _meta: null }); + expect(gatewayToolArg(0, 2)).toMatchObject({ + allowedDecisions: ["allow-once", "deny"], + title: "Allow ChatGPT Meetings to import a meeting?", + twoPhase: true, + }); + }); + + it("does not trust account app ids from non-connector MCP servers", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ + _meta: { app_id: "chatgpt_meetings" }, + }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createAccountAppPolicyContext({ + appId: "chatgpt_meetings", + appName: "ChatGPT Meetings", + allowDestructiveActions: true, + destructiveApprovalMode: "auto", + }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + for (const { name, requestedSchema } of [ { name: "declines connector-id plugin app elicitations with non-object schemas", diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index 3a76a6f9d4d..884e7740b27 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -13,6 +13,7 @@ import { waitForPluginApprovalDecision, } from "./plugin-approval-roundtrip.js"; import type { + CodexAppPolicyContextEntry, PluginAppPolicyContext, PluginAppPolicyContextEntry, } from "./plugin-thread-config.js"; @@ -35,7 +36,7 @@ type BridgeableApprovalElicitation = { type PluginElicitationResolution = | { kind: "not_plugin" } - | { kind: "matched"; entry: PluginAppPolicyContextEntry } + | { kind: "matched"; entry: CodexAppPolicyContextEntry } | { kind: "decline"; reason: string }; const MCP_TOOL_APPROVAL_KIND = "mcp_tool_call"; @@ -167,6 +168,7 @@ function resolvePluginElicitation(params: { const meta = isJsonObject(requestParams["_meta"]) ? requestParams["_meta"] : {}; const context = params.pluginAppPolicyContext; const entries = context ? Object.values(context.apps) : []; + const pluginEntries = entries.filter(isPluginAppPolicyContextEntry); const appId = readFirstString(meta, PLUGIN_APP_ID_META_KEYS) ?? @@ -181,6 +183,9 @@ function resolvePluginElicitation(params: { return { kind: "decline", reason: "missing_policy_context" }; } const entry = context.apps[appId]; + if (entry?.source === "account" && !isCodexConnectorApproval) { + return { kind: "decline", reason: "account_app_source_mismatch" }; + } return uniquePluginMatch(entry ? [entry] : [], "app_id"); } if (isCodexConnectorApproval && connectorId) { @@ -202,7 +207,7 @@ function resolvePluginElicitation(params: { const metadataResolution = resolvePluginStableMetadataMatch({ meta, requestParams, - entries, + entries: pluginEntries, context, }); if (metadataResolution.kind !== "not_plugin") { @@ -261,7 +266,7 @@ function resolvePluginStableMetadataMatch(params: { } function uniquePluginMatch( - matches: PluginAppPolicyContextEntry[], + matches: CodexAppPolicyContextEntry[], source: string, ): PluginElicitationResolution { if (matches.length === 1 && matches[0]) { @@ -275,7 +280,7 @@ function uniquePluginMatch( function hasDisplayNameOnlyPluginMatch( meta: JsonObject, - entries: PluginAppPolicyContextEntry[], + entries: CodexAppPolicyContextEntry[], ): boolean { const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); if (!connectorName) { @@ -284,17 +289,28 @@ function hasDisplayNameOnlyPluginMatch( const normalized = normalizePluginIdentityText(connectorName); return entries.some( (entry) => - normalizePluginIdentityText(entry.pluginName) === normalized || - normalizePluginIdentityText(entry.configKey) === normalized, + normalizePluginIdentityText(appPolicyDisplayName(entry)) === normalized || + (isPluginAppPolicyContextEntry(entry) && + normalizePluginIdentityText(entry.configKey) === normalized), ); } +function isPluginAppPolicyContextEntry( + entry: CodexAppPolicyContextEntry, +): entry is PluginAppPolicyContextEntry { + return entry.source !== "account"; +} + +function appPolicyDisplayName(entry: CodexAppPolicyContextEntry): string { + return isPluginAppPolicyContextEntry(entry) ? entry.pluginName : entry.appName; +} + function normalizePluginIdentityText(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); } async function buildPluginPolicyElicitationResponse(params: { - entry: PluginAppPolicyContextEntry; + entry: CodexAppPolicyContextEntry; requestParams: JsonObject; paramsForRun: EmbeddedRunAttemptParams; signal?: AbortSignal; @@ -331,7 +347,7 @@ async function buildPluginPolicyElicitationResponse(params: { } function resolvePluginDestructiveApprovalMode( - entry: PluginAppPolicyContextEntry, + entry: CodexAppPolicyContextEntry, ): "allow" | "deny" | "auto" | "ask" { return entry.destructiveApprovalMode ?? (entry.allowDestructiveActions ? "allow" : "deny"); } @@ -355,7 +371,7 @@ function oneShotPluginPolicyApprovalOutcome( } function readPluginApprovalElicitation( - entry: PluginAppPolicyContextEntry, + entry: CodexAppPolicyContextEntry, requestParams: JsonObject, ): BridgeableApprovalElicitation | undefined { if ( @@ -377,7 +393,7 @@ function readPluginApprovalElicitation( sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex plugin approval"; const descriptionMeta: JsonObject = { ...meta }; if (!readString(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) { - descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = entry.pluginName; + descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = appPolicyDisplayName(entry); } return { title, 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 3f522e408a3..603661d57b0 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -664,6 +664,231 @@ describe("Codex plugin thread config", () => { expect(config.policyContext.apps).toStrictEqual({}); }); + it("exposes every accessible account app from a complete app inventory", async () => { + const pluginConfig = { + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: false, + }, + }; + expect(shouldBuildCodexPluginThreadConfig(pluginConfig)).toBe(true); + const appListParams: v2.AppsListParams[] = []; + const config = await buildCodexPluginThreadConfig({ + pluginConfig, + appCacheKey: "runtime", + request: async (method, rawParams) => { + if (method !== "app/list") { + throw new Error(`unexpected request ${method}`); + } + const params = rawParams as v2.AppsListParams; + appListParams.push(params); + if (!params.cursor) { + return { + data: [ + { ...appInfo("chatgpt-meetings", true, false), name: "ChatGPT Meetings" }, + appInfo("inaccessible-app", false), + ], + nextCursor: "page-2", + }; + } + return { + data: [{ ...appInfo("slack", true), name: "Slack" }], + nextCursor: null, + }; + }, + }); + + expect(appListParams).toEqual([ + { cursor: undefined, limit: 100, forceRefetch: false }, + { cursor: "page-2", limit: 100, forceRefetch: false }, + ]); + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + "chatgpt-meetings": { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + default_tools_approval_mode: "auto", + }, + slack: { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + default_tools_approval_mode: "auto", + }, + }, + }); + expect(config.policyContext.apps).toEqual({ + "chatgpt-meetings": { + source: "account", + appName: "ChatGPT Meetings", + allowDestructiveActions: false, + destructiveApprovalMode: "deny", + mcpServerNames: [], + }, + slack: { + source: "account", + appName: "Slack", + allowDestructiveActions: false, + destructiveApprovalMode: "deny", + mcpServerNames: [], + }, + }); + expect(config.diagnostics).toStrictEqual([]); + }); + + it("fails closed when the account app inventory cannot be read", async () => { + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: false, + }, + }, + appCacheKey: "runtime", + request: async (method) => { + if (method === "app/list") { + throw new Error("inventory unavailable"); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toStrictEqual({}); + expect(config.diagnostics).toContainEqual({ + code: "account_app_inventory_unavailable", + message: "Codex account app inventory was unavailable; account apps were not exposed.", + }); + }); + + it("clears durable approval overrides for account apps in ask mode", async () => { + let configReadCount = 0; + const request = vi.fn(async (method: string) => { + if (method === "app/list") { + return { + data: [{ ...appInfo("chatgpt-meetings", true), name: "ChatGPT Meetings" }], + nextCursor: null, + }; + } + if (method === "config/read") { + configReadCount += 1; + return { + config: { + apps: { + "chatgpt-meetings": { + tools: + configReadCount === 1 + ? { import_meeting: { approval_mode: "approve" } } + : {}, + }, + }, + }, + }; + } + if (method === "config/value/write") { + return { status: "ok" }; + } + throw new Error(`unexpected request ${method}`); + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "ask", + }, + }, + appCacheKey: "runtime", + request, + }); + + expect((config.configPatch?.apps as Record)?.["chatgpt-meetings"]).toEqual({ + enabled: true, + approvals_reviewer: "user", + destructive_enabled: true, + open_world_enabled: true, + default_tools_approval_mode: "auto", + }); + expect(request).toHaveBeenCalledWith("config/value/write", { + keyPath: 'apps."chatgpt-meetings".tools."import_meeting".approval_mode', + value: null, + mergeStrategy: "replace", + }); + }); + + it("does not re-admit an excluded plugin-owned app through account-wide policy", async () => { + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + allow_all_plugins: true, + allow_destructive_actions: "auto", + plugins: { + meetings: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "meetings", + allow_destructive_actions: "ask", + }, + }, + }, + }, + appCacheKey: "runtime", + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("meetings", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("meetings", [appSummary("chatgpt-meetings")]); + } + if (method === "app/list") { + return { + data: [{ ...appInfo("chatgpt-meetings", true), name: "ChatGPT Meetings" }], + nextCursor: null, + }; + } + if (method === "config/read") { + throw new Error("approval policy unavailable"); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toStrictEqual({}); + expect(config.diagnostics).toContainEqual( + expect.objectContaining({ + code: "approval_overrides_clear_failed", + message: + "Could not clear durable Codex app approval overrides for chatgpt-meetings: approval policy unavailable", + }), + ); + }); + it("does not let per-plugin enablement override disabled native plugin support", async () => { expect( shouldBuildCodexPluginThreadConfig({ diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 0a7b73a72dd..bd2c9a79528 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -1,6 +1,6 @@ /** - * Builds Codex thread config patches that expose only policy-approved - * plugin-owned apps for native Codex turns. + * Builds Codex thread config patches that expose only policy-approved apps + * for native Codex turns. */ import crypto from "node:crypto"; import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; @@ -29,10 +29,11 @@ import { type CodexPluginOwnedApp, type CodexPluginRuntimeRequest, } from "./plugin-inventory.js"; -import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; +import { isJsonObject, type JsonObject, type JsonValue, type v2 } from "./protocol.js"; /** Policy context for one app id exposed by a configured Codex plugin. */ export type PluginAppPolicyContextEntry = { + source?: "plugin"; configKey: string; marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"]; pluginName: string; @@ -41,10 +42,24 @@ export type PluginAppPolicyContextEntry = { mcpServerNames: string[]; }; +/** Policy context for one account-connected app admitted without a plugin package. */ +export type AccountAppPolicyContextEntry = { + source: "account"; + appName: string; + allowDestructiveActions: boolean; + destructiveApprovalMode?: CodexPluginDestructiveApprovalMode; + mcpServerNames: string[]; +}; + +/** Policy context for any app exposed to a native Codex thread. */ +export type CodexAppPolicyContextEntry = + | PluginAppPolicyContextEntry + | AccountAppPolicyContextEntry; + /** Stable app-to-plugin ownership context persisted with Codex thread bindings. */ export type PluginAppPolicyContext = { fingerprint: string; - apps: Record; + apps: Record; pluginAppIds: Record; }; @@ -52,7 +67,11 @@ export type PluginAppPolicyContext = { export type CodexPluginThreadConfigDiagnostic = | CodexPluginInventoryDiagnostic | { - code: "plugin_activation_failed" | "app_not_ready" | "approval_overrides_clear_failed"; + code: + | "plugin_activation_failed" + | "app_not_ready" + | "account_app_inventory_unavailable" + | "approval_overrides_clear_failed"; plugin?: ResolvedCodexPluginPolicy; message: string; }; @@ -78,8 +97,8 @@ export type BuildCodexPluginThreadConfigParams = { nowMs?: number; }; -const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 2; -const CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION = 1; +const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 3; +const CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION = 2; /** Returns true when plugin config exists and thread config may need app patches. */ export function shouldBuildCodexPluginThreadConfig(pluginConfig?: unknown): boolean { @@ -117,15 +136,17 @@ export async function buildCodexPluginThreadConfig( }); } - let inventory = await readCodexPluginInventory({ - pluginConfig: params.pluginConfig, - policy, - request: params.request, - appCache, - appCacheKey: params.appCacheKey, - nowMs: params.nowMs, - suppressAppInventoryRefresh: true, - }); + let inventory = policy.pluginPolicies.length > 0 + ? await readCodexPluginInventory({ + pluginConfig: params.pluginConfig, + policy, + request: params.request, + appCache, + appCacheKey: params.appCacheKey, + nowMs: params.nowMs, + suppressAppInventoryRefresh: true, + }) + : emptyCodexPluginInventory(policy); const appInventoryRefreshDeferredForActivation = inventory.records.some((record) => record.activationRequired) && shouldRefreshMissingAppInventory(params, policy, inventory); @@ -221,9 +242,15 @@ export async function buildCodexPluginThreadConfig( }); } + const accountAppsResult: Awaited> = + policy.allowAllPlugins + ? await readAccessibleAccountApps(params, appCache) + : { apps: [] }; + const diagnostics: CodexPluginThreadConfigDiagnostic[] = [ ...inventory.diagnostics, ...activationDiagnostics, + ...(accountAppsResult.diagnostic ? [accountAppsResult.diagnostic] : []), ]; const apps: JsonObject = { _default: { @@ -232,8 +259,13 @@ export async function buildCodexPluginThreadConfig( open_world_enabled: false, }, }; - const policyApps: Record = {}; + const policyApps: Record = {}; const pluginAppIds: Record = {}; + const pluginOwnedAppIds = new Set( + inventory.records.flatMap((record) => + record.appOwnership === "proven" ? record.ownedAppIds : [], + ), + ); for (const record of inventory.records) { const activation = activationResults.find( (item) => item.identity.configKey === record.policy.configKey, @@ -266,16 +298,7 @@ export async function buildCodexPluginThreadConfig( ) { continue; } - const appConfig: JsonObject = { - enabled: true, - destructive_enabled: record.policy.allowDestructiveActions, - open_world_enabled: true, - default_tools_approval_mode: "auto", - }; - if (record.policy.destructiveApprovalMode === "ask") { - appConfig.approvals_reviewer = "user"; - } - apps[app.id] = appConfig; + apps[app.id] = buildEnabledAppConfig(record.policy); policyApps[app.id] = { configKey: record.policy.configKey, marketplaceName: record.policy.marketplaceName, @@ -287,6 +310,35 @@ export async function buildCodexPluginThreadConfig( } } + for (const app of accountAppsResult.apps) { + // An explicit plugin policy is more specific than the account-wide policy. + // Reserve proven ownership even when activation/readiness fails so a broad + // account policy cannot re-admit an app that the explicit path excluded. + if (pluginOwnedAppIds.has(app.id)) { + continue; + } + const accountApp = toOwnedAccountApp(app); + if ( + policy.destructiveApprovalMode === "ask" && + !(await clearPersistedAppToolApprovalOverrides({ + request: params.request, + configCwd: params.configCwd, + app: accountApp, + diagnostics, + })) + ) { + continue; + } + apps[app.id] = buildEnabledAppConfig(policy); + policyApps[app.id] = { + source: "account", + appName: app.name, + allowDestructiveActions: policy.allowDestructiveActions, + destructiveApprovalMode: policy.destructiveApprovalMode, + mcpServerNames: [], + }; + } + const configPatch = { apps }; const policyContext = buildPluginAppPolicyContext(policyApps, pluginAppIds); return { @@ -375,6 +427,19 @@ function buildDisabledAppsConfigPatch(): JsonObject { }; } +function buildEnabledAppConfig(policy: { + allowDestructiveActions: boolean; + destructiveApprovalMode: CodexPluginDestructiveApprovalMode; +}): JsonObject { + return { + enabled: true, + destructive_enabled: policy.allowDestructiveActions, + open_world_enabled: true, + default_tools_approval_mode: "auto", + ...(policy.destructiveApprovalMode === "ask" ? { approvals_reviewer: "user" } : {}), + }; +} + /** Rebuilds the safe per-thread apps patch persisted with a Codex thread binding. */ export function buildCodexPluginAppsConfigPatchFromPolicyContext( policyContext: PluginAppPolicyContext, @@ -401,11 +466,11 @@ export function buildCodexPluginAppsConfigPatchFromPolicyContext( } function buildPluginAppPolicyContext( - apps: Record, + apps: Record, pluginAppIds: Record, ): PluginAppPolicyContext { return { - fingerprint: fingerprintJson({ version: 1, apps, pluginAppIds }), + fingerprint: fingerprintJson({ version: 2, apps, pluginAppIds }), apps, pluginAppIds, }; @@ -414,7 +479,7 @@ function buildPluginAppPolicyContext( async function clearPersistedAppToolApprovalOverrides(params: { request: CodexPluginRuntimeRequest; configCwd?: string; - plugin: ResolvedCodexPluginPolicy; + plugin?: ResolvedCodexPluginPolicy; app: CodexPluginOwnedApp; diagnostics: CodexPluginThreadConfigDiagnostic[]; }): Promise { @@ -442,7 +507,7 @@ async function clearPersistedAppToolApprovalOverrides(params: { } catch (error) { params.diagnostics.push({ code: "approval_overrides_clear_failed", - plugin: params.plugin, + ...(params.plugin ? { plugin: params.plugin } : {}), message: `Could not clear durable Codex app approval overrides for ${params.app.id}: ${ error instanceof Error ? error.message : String(error) }`, @@ -552,6 +617,54 @@ function collectInventoryOwnedAppIds(inventory: CodexPluginInventory): string[] ).toSorted(); } +function emptyCodexPluginInventory(policy: ResolvedCodexPluginsPolicy): CodexPluginInventory { + return { + policy, + records: [], + diagnostics: [], + }; +} + +async function readAccessibleAccountApps( + params: BuildCodexPluginThreadConfigParams, + appCache: CodexAppInventoryCache, +): Promise<{ + apps: v2.AppInfo[]; + diagnostic?: CodexPluginThreadConfigDiagnostic; +}> { + // Account-wide mode needs a complete inventory. A plugin-targeted cache fill can + // stop once its known app ids are found, so always traverse all app/list pages here. + const snapshot = await refreshAppInventoryNow(params, appCache, { + forceRefetch: false, + reason: "account_apps_all", + targetAppIds: [], + }); + if (!snapshot) { + return { + apps: [], + diagnostic: { + code: "account_app_inventory_unavailable", + message: "Codex account app inventory was unavailable; account apps were not exposed.", + }, + }; + } + return { + apps: snapshot.apps.filter((app) => app.isAccessible).toSorted((left, right) => + left.id.localeCompare(right.id), + ), + }; +} + +function toOwnedAccountApp(app: v2.AppInfo): CodexPluginOwnedApp { + return { + id: app.id, + name: app.name, + accessible: app.isAccessible, + enabled: app.isEnabled, + needsAuth: !app.isAccessible, + }; +} + function resolveThreadConfigAppsForRecord(params: { record: CodexPluginInventoryRecord; inventory: CodexPluginInventory; @@ -591,6 +704,7 @@ function shouldForceRefreshForNotReadyPluginApps( function policyFingerprint(policy: ResolvedCodexPluginsPolicy): JsonValue { return { enabled: policy.enabled, + allowAllPlugins: policy.allowAllPlugins, allowDestructiveActions: policy.allowDestructiveActions, destructiveApprovalMode: policy.destructiveApprovalMode, plugins: policy.pluginPolicies.map((plugin) => ({ diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 7cba649b48c..22c19a97f26 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -174,6 +174,32 @@ describe("codex app-server session binding", () => { expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); }); + it("round-trips account app policy context", async () => { + const sessionFile = path.join(tempDir, "session.json"); + const pluginAppPolicyContext = { + fingerprint: "account-policy-1", + apps: { + "chatgpt-meetings": { + source: "account" as const, + appName: "ChatGPT Meetings", + allowDestructiveActions: true, + destructiveApprovalMode: "auto" as const, + mcpServerNames: [], + }, + }, + pluginAppIds: {}, + }; + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-123", + cwd: tempDir, + pluginAppPolicyContext, + }); + + const binding = await readCodexAppServerBinding(sessionFile); + + expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); + }); + it("drops old always plugin app policy context destructive approval mode", async () => { const sessionFile = path.join(tempDir, "session.json"); await fs.writeFile( diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 46fa7cfe4cb..343a487795f 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -348,15 +348,37 @@ function readPluginAppPolicyContext( entry.destructiveApprovalMode, bindingSchemaVersion, ); + const mcpServerNamesValid = + Array.isArray(entry.mcpServerNames) && + entry.mcpServerNames.every((serverName) => typeof serverName === "string"); + if (entry.source === "account") { + if ( + "appId" in entry || + typeof entry.appName !== "string" || + typeof entry.allowDestructiveActions !== "boolean" || + destructiveApprovalMode === "invalid" || + !mcpServerNamesValid + ) { + return undefined; + } + parsedApps[appId] = { + source: "account", + appName: entry.appName, + allowDestructiveActions: entry.allowDestructiveActions, + ...(destructiveApprovalMode ? { destructiveApprovalMode } : {}), + mcpServerNames: entry.mcpServerNames as string[], + }; + continue; + } if ( "appId" in entry || + (entry.source !== undefined && entry.source !== "plugin") || typeof entry.configKey !== "string" || entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || typeof entry.pluginName !== "string" || typeof entry.allowDestructiveActions !== "boolean" || destructiveApprovalMode === "invalid" || - !Array.isArray(entry.mcpServerNames) || - entry.mcpServerNames.some((serverName) => typeof serverName !== "string") + !mcpServerNamesValid ) { return undefined; } @@ -366,7 +388,7 @@ function readPluginAppPolicyContext( pluginName: entry.pluginName, allowDestructiveActions: entry.allowDestructiveActions, ...(destructiveApprovalMode ? { destructiveApprovalMode } : {}), - mcpServerNames: entry.mcpServerNames, + mcpServerNames: entry.mcpServerNames as string[], }; } const parsedPluginAppIds: PluginAppPolicyContext["pluginAppIds"] = {};