diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 547a012d2559..0a3be7dac734 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -e3ad73cabe4197da73e8bfac98933a647b821d3e876d5547a48ebb26d7d635a7 config-baseline.json +2788a2f397b519b111105ce543db300c1cdc4c3d67993ce31c8e4aa918dc5279 config-baseline.json 1b4ea34ca4c7edcbb29173dd7ebed0a35077dda23e95093c4e199606d505d179 config-baseline.core.json -923a34547068a6fb3230c6a5744b430e81cf2a83e0028b3688d27eb6acd54bc0 config-baseline.channel.json -c5e6594d765117bf4d0e060c0189afbcbd12e8eeec806e4bbab08fa53eb48ecf config-baseline.plugin.json +82596aeb4c8d4cd18fefae554ff4d0c9e2cd82895b53d460db8f8ceb819f0ef6 config-baseline.channel.json +168e3c52da484c235fbc451dead71af16c3bee0c0186c460e5b4b4af70a496df config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 848aecb64682..dabe1ea86744 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -310,7 +310,7 @@ conversation bindings, or any non-Codex harness. 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. + default destructive-action policy for configured plugin app elicitations. Use `true` to accept safe Codex approval schemas without prompting, `false` to decline them, `"auto"` to route Codex-required approvals through OpenClaw plugin approvals, or `"ask"` to prompt for every plugin write/destructive @@ -319,12 +319,17 @@ conversation bindings, or any non-Codex harness. approvals reviewer for that app before the Codex thread starts. Default: `true`. - `plugins.entries.codex.config.codexPlugins.plugins..enabled`: enables a - migrated plugin entry when global `codexPlugins.enabled` is also true. + configured plugin entry when global `codexPlugins.enabled` is also true. Default: `true` for explicit entries. - `plugins.entries.codex.config.codexPlugins.plugins..marketplaceName`: - stable marketplace identity. V1 only supports `"openai-curated"`. + stable marketplace identity, required with `pluginName` for every resolved + entry. Supports `"openai-curated"` and `"workspace-directory"`. Entries + missing either identity field are ignored. - `plugins.entries.codex.config.codexPlugins.plugins..pluginName`: stable - Codex plugin identity from migration, for example `"google-calendar"`. + Codex plugin identity, required with `marketplaceName`. A + `workspace-directory` entry must use the exact marketplace-qualified + `summary.id` returned by `plugin/list`, for example + `"example-plugin@workspace-directory"`. - `plugins.entries.codex.config.codexPlugins.plugins..allow_destructive_actions`: per-plugin destructive-action override. When omitted, the global `allow_destructive_actions` value is used. The per-plugin value accepts the @@ -335,10 +340,17 @@ to the human reviewer. Other apps and non-app thread approvals keep their configured reviewer, so mixed plugin policies do not inherit `"ask"` behavior. `codexPlugins.enabled` is the global enablement directive. Explicit plugin -entries written by migration are the durable install and repair eligibility set. -`plugins["*"]` is not supported, there is no `install` switch, and local -`marketplacePath` values are intentionally not config fields because they are -host-specific. +entries written by migration are the durable curated install and repair +eligibility set. Manually configured `workspace-directory` entries must already +be installed and enabled, and their owned apps must be accessible; OpenClaw +does not install or authenticate them. If Codex rejects the explicit workspace +catalog request, enabled workspace entries fail closed with +`marketplace_missing` while curated entries from the default catalog remain +available. `plugins["*"]` is not supported, there is no `install` switch, and +local `marketplacePath` values are intentionally not config fields because they +are host-specific. See +[Native Codex plugins](/plugins/codex-native-plugins) for app-server version and +readiness requirements. `app/list` readiness checks are cached for one hour and refreshed asynchronously when stale. Codex thread app config is computed at Codex harness diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 24442e171073..52256288d95e 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -1,9 +1,10 @@ --- -summary: "Configure migrated native Codex plugins for Codex-mode OpenClaw agents" +summary: "Configure native Codex plugins for Codex-mode OpenClaw agents" title: "Native Codex plugins" read_when: - You want Codex-mode OpenClaw agents to use native Codex plugins - You are migrating source-installed openai-curated Codex plugins + - You are configuring an existing workspace-directory Codex plugin - You are troubleshooting codexPlugins, app inventory, destructive actions, or plugin app diagnostics --- @@ -23,8 +24,12 @@ working. - `plugins.entries.codex.config.codexPlugins.enabled` is `true`. - The target Codex app-server can see the expected marketplace, plugin, and app inventory. -- V1 supports only `openai-curated` plugins that migration observed as +- Migration supports only `openai-curated` plugins that it observed as source-installed in the source Codex home. +- Manually configured `workspace-directory` plugins require a Codex app-server + whose `plugin/list` accepts `marketplaceKinds` and whose pathless workspace + summaries include `remotePluginId`. The plugin must already be installed and + enabled, and its owned apps must be accessible in `app/list`. `codexPlugins` has no effect on OpenClaw-provider runs, ACP conversation bindings, or other harnesses, because those paths never create Codex @@ -86,6 +91,44 @@ config looks like this: } ``` +Migration remains limited to `openai-curated`. To use an existing +`workspace-directory` plugin, add it manually with the exact +marketplace-qualified `summary.id` returned by `plugin/list`. For example, if +Codex returns `example-plugin@workspace-directory`, configure that complete +value instead of its display name: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + plugins: { + "example-plugin": { + enabled: true, + marketplaceName: "workspace-directory", + pluginName: "example-plugin@workspace-directory", + }, + }, + }, + }, + }, + }, + }, +} +``` + +OpenClaw does not call `plugin/install` or start authentication for a +`workspace-directory` plugin. Install, enable, and authenticate it in Codex +before adding or enabling the OpenClaw policy. OpenClaw keeps apps hidden when +the response omits the exact marketplace, plugin ID, detail ID, or app-readiness +evidence. If Codex rejects the explicit workspace `plugin/list` request, +OpenClaw reports `marketplace_missing` for each enabled workspace plugin and +keeps any independently discovered curated plugins available. + After a `codexPlugins` change, new Codex conversations pick up the updated app set automatically. Run `/new` or `/reset` to refresh the current conversation. A gateway restart is not required for plugin enable/disable @@ -112,20 +155,23 @@ from `plugins.entries.codex.config.codexPlugins.plugins`. gateway client with the `operator.admin` scope can run them. Enabling a configured plugin also turns on the global `codexPlugins.enabled` -switch. If the plugin was written disabled because migration returned +switch. If a curated plugin was written disabled because migration returned `auth_required`, reauthorize the app in Codex before enabling it in OpenClaw. +For a `workspace-directory` entry, enabling it here changes only OpenClaw +policy; the plugin and app must already be active in Codex. ## How native plugin setup works The integration tracks three states: -| State | Meaning | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Installed | Codex has the local plugin bundle in the target app-server runtime. | -| Enabled | OpenClaw config allows the plugin for Codex harness turns. | -| Accessible | Codex app-server confirms the plugin's app entries are available for the active account and map to the migrated plugin identity. | +| State | Meaning | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Installed | Codex has the plugin bundle in the target app-server runtime. | +| Enabled | Codex reports the plugin enabled, and OpenClaw config allows it for Codex harness turns. | +| Accessible | Codex app-server confirms the plugin's app entries are available for the active account and map to the configured plugin identity. | -Migration is the durable install/eligibility step: +For `openai-curated` plugins, migration is the durable install/eligibility +step: - During planning, OpenClaw reads source Codex `plugin/read` details and checks that the source Codex app-server account is a ChatGPT subscription @@ -141,10 +187,18 @@ Migration is the durable install/eligibility step: failures then fall through to the source app-inventory gate instead of skipping outright. -Runtime app inventory is the target-session accessibility check that runs -after migration. Codex harness session setup computes a restrictive thread -app config from the enabled and accessible plugin apps; it is not -recomputed on every turn, so `/codex plugins enable`/`disable` only affect +For `workspace-directory` plugins, setup happens outside OpenClaw. OpenClaw +queries that marketplace only when at least one enabled workspace entry is +configured, resolves each plugin by exact `summary.id`, and reuses the existing +`plugin/read` ownership and `app/list` readiness checks. An uninstalled, +disabled, inaccessible, or unauthenticated plugin exposes no apps; OpenClaw +does not attempt installation or authentication. + +Runtime app inventory is the target-session accessibility check for both +migrated curated plugins and manually configured workspace plugins. Codex +harness session setup computes a restrictive thread app config from the enabled +and accessible plugin apps; it is not recomputed on every turn, so +`/codex plugins enable`/`disable` only affect new Codex conversations. Use `/new` or `/reset` to pick up the change in the current conversation. @@ -152,6 +206,14 @@ current conversation. - Only `openai-curated` plugins already installed in the source Codex app-server inventory are migration-eligible. +- Runtime also supports explicit `workspace-directory` entries on app-server + builds whose `plugin/list` implements `marketplaceKinds` and returns + `remotePluginId` for pathless workspace summaries. These entries must use + their exact marketplace-qualified `summary.id` and must already be installed, + enabled, and app-accessible. A rejected workspace list request produces the + existing per-plugin `marketplace_missing` diagnostic; missing marketplace, + plugin, detail, or app evidence exposes no workspace app. Curated inventory + from the default list request remains usable. - App-backed source plugins must pass the migration-time subscription gate. `--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated accounts, and in verification mode inaccessible/disabled/missing source @@ -163,9 +225,10 @@ current conversation. - `codexPlugins.enabled` is the only global enablement switch; there is no `plugins["*"]` wildcard or config key that grants arbitrary install authority. -- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config - files are preserved in the migration report for manual review, not - activated automatically. +- Non-curated marketplaces, cached plugin bundles, hooks, and Codex config + files are preserved in the migration report for manual review, not activated + automatically. Runtime accepts manually configured `workspace-directory` + entries; other marketplaces remain unsupported. ## App inventory and ownership @@ -179,11 +242,12 @@ Migration and runtime use separate cache keys: - Source migration verification uses the source Codex home and start options. It runs only with `--verify-plugin-apps` and forces a fresh source `app/list` traversal for that planning run. -- Target runtime setup uses the target agent's Codex app-server identity - when building the thread app config. Plugin activation invalidates that +- Target runtime setup uses the target agent's Codex app-server identity when + building the thread app config. Curated plugin activation invalidates that target cache key, then force-refreshes it after `plugin/install`. + `workspace-directory` setup never runs this activation path. -A plugin app is exposed only when OpenClaw can map it back to the migrated +A plugin app is exposed only when OpenClaw can map it back to the configured 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. @@ -226,7 +290,7 @@ 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 or +`_default` is disabled, and only apps owned by enabled configured plugins or accessible account apps admitted by `allow_all_plugins` are enabled. `destructive_enabled` on each app comes from the effective global or @@ -244,7 +308,7 @@ controlled by each app's `destructive_enabled` policy. ## Destructive action policy -Destructive plugin elicitations are allowed by default for migrated Codex +Destructive plugin elicitations are allowed by default for configured Codex plugins, while unsafe schemas and ambiguous ownership fail closed: - Global `allow_destructive_actions` defaults to `true`. @@ -276,10 +340,31 @@ plugins, while unsafe schemas and ambiguous ownership fail closed: | `app_inventory_unavailable` | Strict source app verification was requested but the source Codex app inventory refresh failed. | Fix source Codex app-server access, or retry without `--verify-plugin-apps` to accept the faster account-gated plan. | | `codex_subscription_required` | The source Codex app-server account was not a ChatGPT subscription account. | Log in to the Codex app with subscription auth, then rerun migration. | | `codex_account_unavailable` | The source Codex app-server account could not be read. | Fix source Codex app-server auth, or rerun with `--verify-plugin-apps` to let source app inventory decide eligibility. | -| `marketplace_missing`, `plugin_missing` | The target Codex app-server cannot see the expected `openai-curated` marketplace or plugin. | Rerun migration against the target runtime, or inspect Codex app-server plugin status. | +| `marketplace_missing`, `plugin_missing` | Marketplace or exact plugin unavailable; the explicit workspace catalog request may have been rejected; workspace apps fail closed. | Verify the compatible app-server contract and exact ID described below. | +| `plugin_detail_unavailable` | OpenClaw could not read plugin ownership details. | Inspect the target app-server's `plugin/list` and `plugin/read` responses. | +| `plugin_disabled` | Codex reports the plugin installed but disabled. | Curated activation may repair it; enable a workspace plugin in Codex before retrying. | +| `plugin_activation_failed` | Plugin activation did not complete. | Use the attached diagnostic to distinguish marketplace, auth, refresh, or workspace-readiness failures. | | `app_inventory_missing`, `app_inventory_stale` | App readiness came from an empty or stale cache. | OpenClaw schedules an async refresh automatically; plugin apps stay excluded until ownership and readiness are known. | | `app_ownership_ambiguous` | App inventory only matched by display name. | The app stays hidden from the Codex thread until a later refresh proves ownership. | +**Workspace plugin is installed but not visible:** confirm the workspace +`plugin/list` result reports the exact configured ID as installed and enabled, +then confirm `app/list` reports every owned app accessible for the same Codex +account. OpenClaw can enable an accessible app for the thread even when the +account inventory currently reports that app disabled. If you changed that state after the gateway cached app +inventory, wait for the one-hour cache refresh or restart the gateway, then use +`/new` or `/reset`. OpenClaw does not repair or authenticate workspace plugins. +If the explicit workspace list request is rejected, each enabled workspace +entry reports `marketplace_missing`; unrelated curated entries still proceed +from the default list response. + +For `plugin_detail_unavailable`, a pathless workspace summary must include +`remotePluginId`; OpenClaw keeps owned apps hidden when that selector or the +subsequent `plugin/read` result is unavailable. For +`plugin_activation_failed`, curated plugins may report a marketplace, auth, or +post-install refresh failure. A workspace plugin reports this code when it is +not already active; install, enable, and authenticate it outside OpenClaw. + **Config changed but the agent cannot see the plugin:** run `/codex plugins list` to confirm the configured state, then `/new` or `/reset`. Existing Codex thread bindings keep the app config they started with until OpenClaw diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index c33f9bb1b498..27f6ad9ae81b 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -161,7 +161,7 @@ }, "marketplaceName": { "type": "string", - "enum": ["openai-curated"] + "enum": ["openai-curated", "workspace-directory"] }, "pluginName": { "type": "string" @@ -464,7 +464,7 @@ }, "codexPlugins.enabled": { "label": "Enable Native Plugins", - "help": "Expose explicit migrated Codex plugin entries to Codex harness turns.", + "help": "Expose explicit Codex plugin entries to Codex harness turns.", "advanced": true }, "codexPlugins.allow_all_plugins": { @@ -478,8 +478,8 @@ "advanced": true }, "codexPlugins.plugins": { - "label": "Migrated Plugin Entries", - "help": "Explicit migration-authored plugin entries. The wildcard key * is not supported.", + "label": "Plugin Entries", + "help": "Explicit plugin entries. The wildcard key * is not supported.", "advanced": true }, "supervision": { diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 583a19de0908..2aaf7f4f97ee 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -1473,7 +1473,33 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] }); }); - it("rejects non-curated native plugin identities", () => { + it("parses workspace-directory native plugin identities", () => { + const config = readCodexPluginConfig({ + codexPlugins: { + enabled: true, + plugins: { + workspaceData: { + marketplaceName: "workspace-directory", + pluginName: "workspace-data@workspace-directory", + allow_destructive_actions: "ask", + }, + }, + }, + }); + + expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([ + { + configKey: "workspaceData", + marketplaceName: "workspace-directory", + pluginName: "workspace-data@workspace-directory", + enabled: true, + allowDestructiveActions: true, + destructiveApprovalMode: "ask", + }, + ]); + }); + + it("rejects unsupported native plugin identities", () => { const config = readCodexPluginConfig({ codexPlugins: { enabled: true, @@ -2909,6 +2935,10 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(Object.keys(supervisionEndpointVariants[1]?.properties ?? {}).toSorted()).toEqual( [...CODEX_SUPERVISION_WEBSOCKET_ENDPOINT_CONFIG_KEYS].toSorted(), ); + expect((pluginEntryProperties.marketplaceName as { enum: string[] }).enum).toStrictEqual([ + "openai-curated", + "workspace-directory", + ]); }); it("does not schema-default mode-derived policy fields", async () => { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index b8a99a955457..766f0f32aace 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -75,6 +75,10 @@ export type CodexPluginDestructivePolicy = boolean | "auto" | "ask"; export type CodexPluginDestructiveApprovalMode = "allow" | "deny" | "auto" | "ask"; export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated"; +export const CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME = "workspace-directory"; +export type CodexPluginMarketplaceName = + | typeof CODEX_PLUGINS_MARKETPLACE_NAME + | typeof CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME; export type CodexComputerUseConfig = { enabled?: boolean; @@ -184,7 +188,7 @@ export type ResolvedCodexAppServerNetworkProxyConfig = { export type ResolvedCodexPluginPolicy = { configKey: string; - marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + marketplaceName: CodexPluginMarketplaceName; pluginName: string; enabled: boolean; allowDestructiveActions: boolean; @@ -441,7 +445,9 @@ const codexAppServerNetworkProxySchema = z const codexPluginEntryConfigSchema = z .object({ enabled: z.boolean().optional(), - marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(), + marketplaceName: z + .enum([CODEX_PLUGINS_MARKETPLACE_NAME, CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME]) + .optional(), pluginName: z.string().trim().min(1).optional(), allow_destructive_actions: codexPluginDestructivePolicySchema.optional(), }) @@ -596,7 +602,7 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex ); const pluginPolicies = Object.entries(config?.plugins ?? {}) .flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => { - if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) { + if (!isCodexPluginMarketplaceName(entry.marketplaceName) || !entry.pluginName) { return []; } const entryDestructivePolicy = resolveCodexPluginDestructivePolicy( @@ -605,7 +611,7 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex return [ { configKey, - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplaceName: entry.marketplaceName, pluginName: entry.pluginName, enabled: enabled && entry.enabled !== false, allowDestructiveActions: entryDestructivePolicy.allowDestructiveActions, @@ -624,6 +630,14 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex }; } +function isCodexPluginMarketplaceName( + value: string | undefined, +): value is CodexPluginMarketplaceName { + return ( + value === CODEX_PLUGINS_MARKETPLACE_NAME || value === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME + ); +} + function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolicy): { allowDestructiveActions: boolean; destructiveApprovalMode: CodexPluginDestructiveApprovalMode; diff --git a/extensions/codex/src/app-server/plugin-activation.test.ts b/extensions/codex/src/app-server/plugin-activation.test.ts index 14967a9bf3e1..ca2cee688388 100644 --- a/extensions/codex/src/app-server/plugin-activation.test.ts +++ b/extensions/codex/src/app-server/plugin-activation.test.ts @@ -1,7 +1,11 @@ // Codex tests cover plugin activation plugin behavior. import { describe, expect, it, vi } from "vitest"; import { CodexAppInventoryCache } from "./app-inventory-cache.js"; -import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + type ResolvedCodexPluginPolicy, +} from "./config.js"; import { ensureCodexAppsSubstrateConfig, ensureCodexPluginActivation, @@ -286,6 +290,27 @@ describe("Codex plugin activation", () => { ]); }); + it("requires workspace-directory plugins to be activated outside OpenClaw", async () => { + const request = vi.fn(async () => { + throw new Error("workspace activation must not call app-server"); + }); + const result = await ensureCodexPluginActivation({ + identity: { + ...identity("workspace-data@workspace-directory"), + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + }, + request, + }); + + expectActivationResult(result, { + ok: false, + reason: "disabled", + installAttempted: false, + }); + expect(result.diagnostics[0]?.message).toContain("installed and enabled outside OpenClaw"); + expect(request).not.toHaveBeenCalled(); + }); + it("upserts native apps substrate config without clobbering other toml", async () => { const existing = 'model = "gpt-5.5"\n\n[features]\nother = true\n'; expect(upsertTomlBoolean(existing, "features", "apps", true)).toBe( diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts index 863eed81b2d3..aaad357c2c71 100644 --- a/extensions/codex/src/app-server/plugin-activation.ts +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -1,11 +1,15 @@ /** - * Activates configured Codex marketplace plugins and refreshes runtime state so - * plugin-owned apps/tools are visible to native Codex turns. + * Activates curated Codex marketplace plugins and keeps require-active + * marketplaces outside OpenClaw's install authority. */ import fs from "node:fs/promises"; import path from "node:path"; import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js"; -import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + type ResolvedCodexPluginPolicy, +} from "./config.js"; import { findOpenAiCuratedPluginSummary, pluginReadParams, @@ -55,13 +59,14 @@ export type CodexPluginRuntimeRefreshResult = { diagnostics: CodexPluginActivationDiagnostic[]; }; -/** Installs/enables a configured Codex plugin and refreshes plugin/app state. */ +/** Activates a curated plugin or rejects a workspace plugin that is not already active. */ export async function ensureCodexPluginActivation( params: EnsureCodexPluginActivationParams, ): Promise { - if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) { - return activationFailure(params.identity, "marketplace_missing", { - message: "Only openai-curated plugins can be activated.", + if (params.identity.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME) { + return activationFailure(params.identity, "disabled", { + message: + "workspace-directory plugins must be installed and enabled outside OpenClaw before use.", }); } diff --git a/extensions/codex/src/app-server/plugin-inventory.test.ts b/extensions/codex/src/app-server/plugin-inventory.test.ts index c144e08998fc..93172c3ff979 100644 --- a/extensions/codex/src/app-server/plugin-inventory.test.ts +++ b/extensions/codex/src/app-server/plugin-inventory.test.ts @@ -1,7 +1,11 @@ // Codex tests cover plugin inventory plugin behavior. import { describe, expect, it } from "vitest"; import { CodexAppInventoryCache } from "./app-inventory-cache.js"; -import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js"; +import { CodexAppServerRpcError } from "./client.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, +} from "./config.js"; import { findOpenAiCuratedPluginSummary, readCodexPluginInventory } from "./plugin-inventory.js"; import type { v2 } from "./protocol.js"; @@ -200,15 +204,287 @@ describe("Codex plugin inventory", () => { }, }); - expect(inventory.marketplace).toEqual({ - name: CODEX_PLUGINS_MARKETPLACE_NAME, - remoteMarketplaceName: "openai-curated-remote", - }); expect(inventory.records[0]?.ownedAppIds).toStrictEqual(["google-calendar-app"]); expect(inventory.records[0]?.apps[0]?.accessible).toBe(true); expect(inventory.diagnostics).toStrictEqual([]); }); + it("queries workspace-directory only when configured and resolves the exact catalog id", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ data: [appInfo("workspace-data-app", true)], nextCursor: null }), + }); + const calls: Array<{ method: string; params: unknown }> = []; + const exactSummary = pluginSummary("workspace-data@workspace-directory", { + name: "Workspace Data", + remotePluginId: "plugin_workspace_data", + installed: true, + enabled: true, + }); + + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method, params) => { + calls.push({ method, params }); + if (method === "plugin/list" && !(params as v2.PluginListParams).marketplaceKinds) { + return pluginList([]); + } + if (method === "plugin/list") { + return pluginList( + [ + pluginSummary("other-workspace-data@workspace-directory", { + name: "Workspace Data", + remotePluginId: "wrong-workspace-data-id", + installed: true, + enabled: true, + }), + exactSummary, + ], + { name: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, path: null }, + ); + } + if (method === "plugin/read") { + expect(params).toEqual({ + remoteMarketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "plugin_workspace_data", + }); + return pluginDetail("workspace-data", [appSummary("workspace-data-app")], { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + marketplacePath: null, + }); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(calls.slice(0, 2)).toStrictEqual([ + { method: "plugin/list", params: { cwds: [] } }, + { + method: "plugin/list", + params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] }, + }, + ]); + expect(inventory.records[0]?.summary).toBe(exactSummary); + expect(inventory.records[0]?.ownedAppIds).toStrictEqual(["workspace-data-app"]); + expect(inventory.diagnostics).toStrictEqual([]); + }); + + it("does not query workspace-directory for curated-only policy", async () => { + const calls: unknown[] = []; + await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + github: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "github", + }, + }, + }, + }, + readPluginDetails: false, + request: async (method, params) => { + if (method === "plugin/list") { + calls.push(params); + return pluginList([pluginSummary("github", { installed: true, enabled: true })]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(calls).toStrictEqual([{ cwds: [] }]); + }); + + it("fails closed before plugin/read when a workspace summary lacks remotePluginId", async () => { + const calls: string[] = []; + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + }, + }, + }, + }, + request: async (method, params) => { + calls.push(method); + if (method === "plugin/list" && !(params as v2.PluginListParams).marketplaceKinds) { + return pluginList([]); + } + if (method === "plugin/list") { + return pluginList( + [ + pluginSummary("workspace-data@workspace-directory", { + name: "Workspace Data", + installed: true, + enabled: true, + }), + ], + { name: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, path: null }, + ); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(calls).toStrictEqual(["plugin/list", "plugin/list"]); + expect(inventory.records[0]?.detail).toBeUndefined(); + expect(inventory.diagnostics.map((diagnostic) => diagnostic.code)).toStrictEqual([ + "plugin_detail_unavailable", + ]); + }); + + it("keeps curated records when a configured workspace marketplace is missing", async () => { + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + github: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "github", + }, + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + }, + }, + }, + }, + readPluginDetails: false, + request: async (method, params) => { + if (method !== "plugin/list") { + throw new Error(`unexpected request ${method}`); + } + return (params as v2.PluginListParams).marketplaceKinds + ? { marketplaces: [], marketplaceLoadErrors: [], featuredPluginIds: [] } + : pluginList([pluginSummary("github", { installed: true, enabled: true })]); + }, + }); + + expect(inventory.records.map((record) => record.policy.configKey)).toStrictEqual(["github"]); + expect(inventory.diagnostics).toMatchObject([ + { + code: "marketplace_missing", + plugin: { configKey: "workspaceData" }, + }, + ]); + }); + + it("keeps curated records and diagnoses each workspace plugin when its explicit list is rejected", async () => { + const calls: Array<{ method: string; params: unknown }> = []; + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + github: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "github", + }, + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + }, + workspaceMetrics: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-metrics@workspace-directory", + }, + }, + }, + }, + readPluginDetails: false, + request: async (method, params) => { + calls.push({ method, params }); + if (method !== "plugin/list") { + throw new Error(`unexpected request ${method}`); + } + if ((params as v2.PluginListParams).marketplaceKinds) { + throw new CodexAppServerRpcError( + { code: -32_603, message: "list remote plugin catalog failed" }, + method, + ); + } + return pluginList([pluginSummary("github", { installed: true, enabled: true })]); + }, + }); + + expect(calls).toStrictEqual([ + { method: "plugin/list", params: { cwds: [] } }, + { + method: "plugin/list", + params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] }, + }, + ]); + expect(inventory.records.map((record) => record.policy.configKey)).toStrictEqual(["github"]); + expect( + inventory.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + configKey: diagnostic.plugin?.configKey, + message: diagnostic.message, + })), + ).toStrictEqual([ + { + code: "marketplace_missing", + configKey: "workspaceData", + message: "Codex marketplace workspace-directory was not found.", + }, + { + code: "marketplace_missing", + configKey: "workspaceMetrics", + message: "Codex marketplace workspace-directory was not found.", + }, + ]); + }); + + it("does not hide non-RPC failures from the explicit workspace list", async () => { + const failure = new Error("workspace plugin/list transport closed"); + await expect( + readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + }, + }, + }, + }, + readPluginDetails: false, + request: async (method, params) => { + if (method !== "plugin/list") { + throw new Error(`unexpected request ${method}`); + } + if ((params as v2.PluginListParams).marketplaceKinds) { + throw failure; + } + return pluginList([]); + }, + }), + ).rejects.toBe(failure); + }); + it("fails closed when plugin detail apps are absent from app inventory", async () => { const appCache = new CodexAppInventoryCache(); await appCache.refreshNow({ @@ -385,11 +661,18 @@ function pluginSummary(id: string, overrides: Partial = {}): v }; } -function pluginDetail(pluginName: string, apps: v2.AppSummary[]): v2.PluginReadResponse { +function pluginDetail( + pluginName: string, + apps: v2.AppSummary[], + marketplace: { marketplaceName?: string; marketplacePath?: string | null } = {}, +): v2.PluginReadResponse { return { plugin: { - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, - marketplacePath: "/marketplaces/openai-curated", + marketplaceName: marketplace.marketplaceName ?? CODEX_PLUGINS_MARKETPLACE_NAME, + marketplacePath: + marketplace.marketplacePath === undefined + ? "/marketplaces/openai-curated" + : marketplace.marketplacePath, summary: pluginSummary(pluginName, { installed: true, enabled: true }), description: null, skills: [], diff --git a/extensions/codex/src/app-server/plugin-inventory.ts b/extensions/codex/src/app-server/plugin-inventory.ts index 46a9c2e81907..07b882de8589 100644 --- a/extensions/codex/src/app-server/plugin-inventory.ts +++ b/extensions/codex/src/app-server/plugin-inventory.ts @@ -8,9 +8,12 @@ import type { CodexAppInventoryCacheRead, CodexAppInventoryRequest, } from "./app-inventory-cache.js"; +import { CodexAppServerRpcError } from "./client.js"; import { CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, resolveCodexPluginsPolicy, + type CodexPluginMarketplaceName, type ResolvedCodexPluginPolicy, type ResolvedCodexPluginsPolicy, } from "./config.js"; @@ -21,9 +24,13 @@ const CODEX_PLUGINS_REMOTE_MARKETPLACE_NAME = `${CODEX_PLUGINS_MARKETPLACE_NAME} /** Request callback used to call Codex app-server plugin/app methods. */ export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise; -/** Stable reference to the OpenAI curated Codex plugin marketplace. */ +type CodexWorkspacePluginListResult = + | { kind: "listed"; response: v2.PluginListResponse } + | { kind: "rejected" }; + +/** Stable reference to a supported Codex plugin marketplace. */ export type CodexPluginMarketplaceRef = { - name: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + name: CodexPluginMarketplaceName; path?: string; remoteMarketplaceName?: string; }; @@ -70,7 +77,6 @@ export type CodexPluginInventoryRecord = { /** Complete inventory result for configured Codex plugins and owned apps. */ export type CodexPluginInventory = { policy: ResolvedCodexPluginsPolicy; - marketplace?: CodexPluginMarketplaceRef; records: CodexPluginInventoryRecord[]; diagnostics: CodexPluginInventoryDiagnostic[]; appInventory?: CodexAppInventoryCacheRead; @@ -107,26 +113,32 @@ export async function readCodexPluginInventory( } const appInventory = readCachedAppInventory(params); - const listed = (await params.request("plugin/list", { + const curatedListed = (await params.request("plugin/list", { cwds: [], } satisfies v2.PluginListParams)) as v2.PluginListResponse; - const marketplaceEntry = listed.marketplaces.find(isOpenAiCuratedMarketplace); - if (!marketplaceEntry) { - return { - policy, - records: [], - diagnostics: policy.pluginPolicies - .filter((pluginPolicy) => pluginPolicy.enabled) - .map((pluginPolicy) => ({ - code: "marketplace_missing", - plugin: pluginPolicy, - message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`, - })), - ...(appInventory ? { appInventory } : {}), - }; + const shouldListWorkspacePlugins = policy.pluginPolicies.some( + (pluginPolicy) => + pluginPolicy.enabled && + pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + ); + let workspaceListResult: CodexWorkspacePluginListResult | undefined; + if (shouldListWorkspacePlugins) { + try { + workspaceListResult = { + kind: "listed", + response: (await params.request("plugin/list", { + cwds: [], + marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME], + } satisfies v2.PluginListParams)) as v2.PluginListResponse, + }; + } catch (error) { + if (!(error instanceof CodexAppServerRpcError)) { + throw error; + } + workspaceListResult = { kind: "rejected" }; + } } - let marketplace = marketplaceRef(marketplaceEntry); const diagnostics: CodexPluginInventoryDiagnostic[] = []; const records: CodexPluginInventoryRecord[] = []; if (appInventory?.state === "missing") { @@ -145,21 +157,43 @@ export async function readCodexPluginInventory( if (!pluginPolicy.enabled) { continue; } - const resolvedPlugin = findOpenAiCuratedMarketplacePlugin(listed, pluginPolicy.pluginName); + const listed = + pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME + ? workspaceListResult?.kind === "listed" + ? workspaceListResult.response + : undefined + : curatedListed; + const hasMarketplace = + pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME + ? listed?.marketplaces.some( + (entry) => entry.name === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + ) === true + : listed?.marketplaces.some(isOpenAiCuratedMarketplace) === true; + if (!listed || !hasMarketplace) { + diagnostics.push({ + code: "marketplace_missing", + plugin: pluginPolicy, + message: `Codex marketplace ${pluginPolicy.marketplaceName} was not found.`, + }); + continue; + } + const resolvedPlugin = + pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME + ? findWorkspaceMarketplacePlugin(listed, pluginPolicy.pluginName) + : findOpenAiCuratedMarketplacePlugin(listed, pluginPolicy.pluginName); if (!resolvedPlugin) { diagnostics.push({ code: "plugin_missing", plugin: pluginPolicy, - message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`, + message: `${pluginPolicy.pluginName} was not found in ${pluginPolicy.marketplaceName}.`, }); continue; } const { summary } = resolvedPlugin; - const pluginMarketplace = marketplaceRef(resolvedPlugin.marketplace); - if (records.length === 0) { - marketplace = pluginMarketplace; - } - + const pluginMarketplace = marketplaceRef( + resolvedPlugin.marketplace, + pluginPolicy.marketplaceName, + ); const detail = await readPluginDetail( params, pluginMarketplace, @@ -211,7 +245,6 @@ export async function readCodexPluginInventory( const inventory = { policy, - marketplace, records, diagnostics, ...(appInventory ? { appInventory } : {}), @@ -226,7 +259,10 @@ export function findOpenAiCuratedPluginSummary( ): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined { const resolved = findOpenAiCuratedMarketplacePlugin(listed, pluginName); return resolved - ? { marketplace: marketplaceRef(resolved.marketplace), summary: resolved.summary } + ? { + marketplace: marketplaceRef(resolved.marketplace, CODEX_PLUGINS_MARKETPLACE_NAME), + summary: resolved.summary, + } : undefined; } @@ -270,6 +306,18 @@ async function readPluginDetail( if (params.readPluginDetails === false) { return undefined; } + if ( + marketplace.name === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME && + marketplace.remoteMarketplaceName && + !summary.remotePluginId + ) { + diagnostics.push({ + code: "plugin_detail_unavailable", + plugin: pluginPolicy, + message: `${pluginPolicy.pluginName} detail unavailable: Codex did not return a remote plugin id.`, + }); + return undefined; + } try { const response = (await params.request( "plugin/read", @@ -380,6 +428,19 @@ function findOpenAiCuratedMarketplacePlugin( return undefined; } +function findWorkspaceMarketplacePlugin( + listed: v2.PluginListResponse, + pluginName: string, +): { marketplace: v2.PluginMarketplaceEntry; summary: v2.PluginSummary } | undefined { + // Workspace display names are not unique; the configured pluginName is the + // exact catalog id returned by plugin/list. + const marketplace = listed.marketplaces.find( + (entry) => entry.name === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + ); + const summary = marketplace?.plugins.find((plugin) => plugin.id === pluginName); + return marketplace && summary ? { marketplace, summary } : undefined; +} + function pluginNameFromPluginId(pluginId: string, marketplaceName: string): string | undefined { const trimmed = pluginId.trim(); if (!trimmed) { @@ -392,9 +453,12 @@ function pluginNameFromPluginId(pluginId: string, marketplaceName: string): stri return withoutMarketplaceSuffix.split("/").at(-1)?.trim() || undefined; } -function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef { +function marketplaceRef( + marketplace: v2.PluginMarketplaceEntry, + name: CodexPluginMarketplaceName, +): CodexPluginMarketplaceRef { return { - name: CODEX_PLUGINS_MARKETPLACE_NAME, + name, ...(marketplace.path ? { path: marketplace.path } : {}), ...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}), }; 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 b87896ce1f11..0ba0dc009cb6 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -1,7 +1,10 @@ // Codex tests cover plugin thread config plugin behavior. import { describe, expect, it, vi } from "vitest"; import { CodexAppInventoryCache } from "./app-inventory-cache.js"; -import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, +} from "./config.js"; import { buildCodexPluginAppsConfigPatchFromPolicyContext, buildCodexPluginThreadConfig, @@ -83,6 +86,87 @@ describe("Codex plugin thread config", () => { expect(config.diagnostics).toStrictEqual([]); }); + it("reuses the existing app policy path for an active workspace plugin", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("workspace-data-app", true)], + nextCursor: null, + }), + }); + const methods: string[] = []; + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + workspaceData: { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + allow_destructive_actions: false, + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method, params) => { + methods.push(method); + if (method === "plugin/list") { + return (params as v2.PluginListParams).marketplaceKinds + ? pluginList( + [ + pluginSummary("workspace-data@workspace-directory", { + remotePluginId: "plugin_workspace_data", + installed: true, + enabled: true, + }), + ], + { name: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, path: null }, + ) + : pluginList([]); + } + if (method === "plugin/read") { + expect(params).toEqual({ + remoteMarketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "plugin_workspace_data", + }); + return pluginDetail("workspace-data", [appSummary("workspace-data-app")], [], { + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + marketplacePath: null, + }); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(methods).toStrictEqual(["plugin/list", "plugin/list", "plugin/read"]); + expect(config.configPatch?.apps).toEqual({ + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + "workspace-data-app": { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + default_tools_approval_mode: "auto", + }, + }); + expect(config.policyContext.apps["workspace-data-app"]).toMatchObject({ + configKey: "workspaceData", + marketplaceName: CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + pluginName: "workspace-data@workspace-directory", + destructiveApprovalMode: "deny", + }); + expect(config.diagnostics).toStrictEqual([]); + }); + it("maps destructive app access from global and per-plugin policy", async () => { const pluginOverrideDisabled = await buildReadyGoogleCalendarThreadConfig({ codexPlugins: { @@ -1768,12 +1852,15 @@ describe("Codex plugin thread config", () => { }); }); -function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { +function pluginList( + plugins: v2.PluginSummary[], + marketplace: { name?: string; path?: string | null } = {}, +): v2.PluginListResponse { return { marketplaces: [ { - name: CODEX_PLUGINS_MARKETPLACE_NAME, - path: "/marketplaces/openai-curated", + name: marketplace.name ?? CODEX_PLUGINS_MARKETPLACE_NAME, + path: marketplace.path === undefined ? "/marketplaces/openai-curated" : marketplace.path, interface: null, plugins, }, @@ -1802,11 +1889,15 @@ function pluginDetail( pluginName: string, apps: v2.AppSummary[], mcpServers: string[] = [], + marketplace: { marketplaceName?: string; marketplacePath?: string | null } = {}, ): v2.PluginReadResponse { return { plugin: { - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, - marketplacePath: "/marketplaces/openai-curated", + marketplaceName: marketplace.marketplaceName ?? CODEX_PLUGINS_MARKETPLACE_NAME, + marketplacePath: + marketplace.marketplacePath === undefined + ? "/marketplaces/openai-curated" + : marketplace.marketplacePath, summary: pluginSummary(pluginName, { installed: true, enabled: true }), description: null, skills: [], diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index 35ea23f43c6c..c1708dfc8adf 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -554,8 +554,16 @@ export type CodexPluginReadResponse = { plugin: CodexPluginDetail; }; +export type CodexPluginListMarketplaceKind = + | "local" + | "vertical" + | "workspace-directory" + | "shared-with-me" + | "created-by-me-remote"; + export type CodexPluginListParams = { cwds: string[]; + marketplaceKinds?: CodexPluginListMarketplaceKind[]; }; export type CodexPluginReadParams = { diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index d397db12edd6..23af4678b469 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -1140,6 +1140,39 @@ describe("Codex app-server binding store", () => { expect(invalid?.binding.pluginAppPolicyContext).toBeUndefined(); }); + it("round-trips workspace-directory plugin policy context", () => { + const stored = createStoredCodexAppServerBinding({ + schemaVersion: 2, + threadId: "thread-workspace-plugin", + cwd: "/repo", + pluginAppPolicyContext: { + fingerprint: "policy-workspace", + apps: { + workspaceData: { + configKey: "workspaceData", + marketplaceName: "workspace-directory", + pluginName: "workspace-data@workspace-directory", + allowDestructiveActions: true, + destructiveApprovalMode: "ask", + mcpServerNames: [], + }, + }, + pluginAppIds: { workspaceData: ["workspace-data"] }, + }, + }); + + expect(stored?.binding.pluginAppPolicyContext).toMatchObject({ + apps: { + workspaceData: { + marketplaceName: "workspace-directory", + pluginName: "workspace-data@workspace-directory", + destructiveApprovalMode: "ask", + }, + }, + pluginAppIds: { workspaceData: ["workspace-data"] }, + }); + }); + it("serializes writes from another facade behind a native-compaction lease", async () => { vi.useFakeTimers(); const { state } = createStateStore(); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 390ca1a7db73..2d62a789cca8 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -17,7 +17,11 @@ import { resolveStorePath, } from "openclaw/plugin-sdk/session-store-runtime"; import { z } from "zod"; -import { CODEX_PLUGINS_MARKETPLACE_NAME, normalizeCodexServiceTier } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + normalizeCodexServiceTier, +} from "./config.js"; import type { PluginAppPolicyContext } from "./plugin-thread-config.js"; import type { CodexServiceTier } from "./protocol.js"; @@ -140,7 +144,10 @@ const pluginAppPolicyEntrySchema = z .object({ source: z.literal("plugin").optional(), configKey: z.string(), - marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME), + marketplaceName: z.enum([ + CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME, + ]), pluginName: z.string(), allowDestructiveActions: z.boolean(), destructiveApprovalMode: destructiveApprovalModeSchema, @@ -1231,7 +1238,8 @@ function readPluginAppPolicyContext( "appId" in entry || (entry.source !== undefined && entry.source !== "plugin") || typeof entry.configKey !== "string" || - entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME && + entry.marketplaceName !== CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME) || typeof entry.pluginName !== "string" || typeof entry.allowDestructiveActions !== "boolean" || destructiveApprovalMode === "invalid" ||