mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
Let owner-operated Codex agents use connected account apps (#100973)
* Allow connected Codex account apps * Preserve explicit plugin app policy * Refresh Codex documentation map * Unify connected apps with Codex plugin policy * Format Codex configuration reference
This commit is contained in:
parent
2638113c68
commit
806a116f9d
18 changed files with 669 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:/),
|
||||
|
|
|
|||
|
|
@ -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<typeof emitTrustedDiagnosticEventWithPrivateData>[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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export type CodexPluginEntryConfig = {
|
|||
|
||||
export type CodexPluginsConfig = {
|
||||
enabled?: boolean;
|
||||
allow_all_plugins?: boolean;
|
||||
allow_destructive_actions?: CodexPluginDestructivePolicy;
|
||||
plugins?: Record<string, CodexPluginEntryConfig>;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)?.["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({
|
||||
|
|
|
|||
|
|
@ -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<string, PluginAppPolicyContextEntry>;
|
||||
apps: Record<string, CodexAppPolicyContextEntry>;
|
||||
pluginAppIds: Record<string, string[]>;
|
||||
};
|
||||
|
||||
|
|
@ -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<ReturnType<typeof readAccessibleAccountApps>> =
|
||||
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<string, PluginAppPolicyContextEntry> = {};
|
||||
const policyApps: Record<string, CodexAppPolicyContextEntry> = {};
|
||||
const pluginAppIds: Record<string, string[]> = {};
|
||||
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<string, PluginAppPolicyContextEntry>,
|
||||
apps: Record<string, CodexAppPolicyContextEntry>,
|
||||
pluginAppIds: Record<string, string[]>,
|
||||
): 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<boolean> {
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"] = {};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue