diff --git a/CHANGELOG.md b/CHANGELOG.md index 19594e01a77..a9e22553e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads. - Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`. - Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach. +- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand. - Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated. - Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection. - Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 4db6761f0b9..4fa60a9132e 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -202,6 +202,8 @@ Common command routing: | Attach the current chat | `/codex bind [--cwd ]` | | Resume an existing Codex thread | `/codex resume ` | | List or filter Codex threads | `/codex threads [filter]` | +| List native Codex plugins | `/codex plugins list` | +| Enable or disable a configured native Codex plugin | `/codex plugins enable `, `/codex plugins disable ` | | Attach an existing Codex CLI session on a paired node | `/codex sessions --host [filter]`, then `/codex resume --host --bind here` | | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 4c13f4617b3..1716930e320 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -81,8 +81,35 @@ config looks like this: } ``` -After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so -future Codex harness sessions start with the updated app set. +After changing `codexPlugins`, new Codex conversations pick up the updated app +set automatically. Use `/new` or `/reset` to refresh the current conversation. +A gateway restart is not required for plugin enable or disable changes. + +## Manage plugins from chat + +Use `/codex plugins` when you want to inspect or change configured native Codex +plugins from the same chat where you operate the Codex harness: + +```text +/codex plugins +/codex plugins list +/codex plugins disable google-calendar +/codex plugins enable google-calendar +``` + +`/codex plugins` is an alias for `/codex plugins list`. The list output shows +the configured plugin keys, on/off state, Codex plugin name, and marketplace +from `plugins.entries.codex.config.codexPlugins.plugins`. + +`enable` and `disable` write only to OpenClaw config at +`~/.openclaw/openclaw.json`; they do not edit `~/.codex/config.toml` or install +new Codex plugins. Only the owner or a gateway client with the +`operator.admin` scope can change plugin state. + +Enabling a configured plugin also turns on the global +`codexPlugins.enabled` switch. If the plugin was written disabled because +migration returned `auth_required`, reauthorize the app in Codex before enabling +it in OpenClaw. ## How native plugin setup works @@ -110,7 +137,10 @@ check after migration. Codex harness session setup then computes a restrictive thread app config for the enabled and accessible plugin apps. Thread app config is computed when OpenClaw establishes a Codex harness session -or replaces a stale Codex thread binding. It is not recomputed on every turn. +or replaces a stale Codex thread binding. It is not recomputed on every turn, so +`/codex plugins enable` and `/codex plugins disable` affect new Codex +conversations. Use `/new` or `/reset` when the current conversation should pick +up the updated app set. ## V1 support boundary @@ -228,10 +258,10 @@ apps until ownership and readiness are known. **`app_ownership_ambiguous`:** app inventory only matched by display name, so the app is not exposed to the Codex thread. -**Config changed but the agent cannot see the plugin:** use `/new`, `/reset`, or -restart the gateway. Existing Codex thread bindings keep the app config they -started with until OpenClaw establishes a new harness session or replaces a -stale binding. +**Config changed but the agent cannot see the plugin:** use `/codex plugins +list` to confirm the configured state, then use `/new` or `/reset`. Existing +Codex thread bindings keep the app config they started with until OpenClaw +establishes a new harness session or replaces a stale binding. **Destructive action is declined:** check the global and per-plugin `allow_destructive_actions` values. Even when policy is true, unsafe elicitation diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index c83ce601b1b..c32da221340 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -1,9 +1,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createCodexAppServerAgentHarness } from "./harness.js"; import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildCodexProvider } from "./provider.js"; +import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js"; import { createCodexCommand } from "./src/commands.js"; import { handleCodexConversationBindingResolved, @@ -53,6 +55,60 @@ export default definePluginEntry({ listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }), resolveCodexCliSessionForBindingOnNode: (params) => resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }), + codexPluginsManagementIo: { + readConfig: () => { + const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig; + const plugins = (current as Record).plugins; + if (!plugins || typeof plugins !== "object") { + return Promise.resolve({}); + } + const entries = (plugins as Record).entries; + if (!entries || typeof entries !== "object") { + return Promise.resolve({}); + } + const codexEntry = (entries as Record).codex; + if (!codexEntry || typeof codexEntry !== "object") { + return Promise.resolve({}); + } + const config = (codexEntry as Record).config; + if (!config || typeof config !== "object") { + return Promise.resolve({}); + } + const codexPlugins = (config as Record).codexPlugins; + if (!codexPlugins || typeof codexPlugins !== "object") { + return Promise.resolve({}); + } + const declared = (codexPlugins as Record).plugins; + if (!declared || typeof declared !== "object") { + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + }); + } + return Promise.resolve({ + enabled: (codexPlugins as Record).enabled === true, + plugins: declared as Record, + }); + }, + mutate: async (update) => { + await mutateConfigFile({ + mutate: (draft) => { + const root = draft as Record; + root.plugins = (root.plugins ?? {}) as Record; + const pluginsBlock = root.plugins as Record; + pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record; + const entries = pluginsBlock.entries as Record; + entries.codex = (entries.codex ?? {}) as Record; + const codexEntry = entries.codex as Record; + codexEntry.config = (codexEntry.config ?? {}) as Record; + const config = codexEntry.config as Record; + config.codexPlugins = (config.codexPlugins ?? {}) as Record; + const codexPlugins = config.codexPlugins as Record; + codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record; + update(codexPlugins as CodexPluginsConfigBlock); + }, + }); + }, + }, }, }), ); diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 8874001257d..828f79f7769 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -320,6 +320,7 @@ export function buildHelp(): string { "- /codex account", "- /codex mcp", "- /codex skills", + "- /codex plugins [list|enable|disable]", ].join("\n"); } diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 6a78cb96e20..6492b7a1ac3 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -28,6 +28,10 @@ import { formatThreads, readString, } from "./command-formatters.js"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { codexControlRequest, readCodexStatusProbes, @@ -80,6 +84,7 @@ export type CodexCommandDeps = { stopCodexConversationTurn: typeof stopCodexConversationTurn; listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn; resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn; + codexPluginsManagementIo?: CodexPluginsManagementIO; }; type CodexControlRequestFn = ( @@ -228,6 +233,16 @@ export async function handleCodexSubcommand( if (normalized === "help") { return { text: buildHelp() }; } + if (normalized === "plugins") { + if (!deps.codexPluginsManagementIo) { + return { + text: + "Codex sub-plugin management is not wired up (codexPluginsManagementIo dep is undefined). " + + "Edit ~/.openclaw/openclaw.json or use `openclaw config patch` until the runtime exposes the IO.", + }; + } + return await handleCodexPluginsSubcommand(ctx, rest, deps.codexPluginsManagementIo); + } if (normalized === "status") { if (rest.length > 0) { return { text: "Usage: /codex status" }; diff --git a/extensions/codex/src/command-plugins-management.test.ts b/extensions/codex/src/command-plugins-management.test.ts new file mode 100644 index 00000000000..99a0fcfa55a --- /dev/null +++ b/extensions/codex/src/command-plugins-management.test.ts @@ -0,0 +1,172 @@ +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it } from "vitest"; +import { + handleCodexPluginsSubcommand, + type CodexPluginsConfigBlock, + type CodexPluginConfigEntry, + type CodexPluginsManagementIO, +} from "./command-plugins-management.js"; + +function inMemoryIO( + initial: Record = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + +const fakeCtx: PluginCommandContext = { + args: "", + config: {}, + channel: "test", + isAuthorizedSender: true, + senderIsOwner: true, + commandBody: "/codex plugins", + requestConversationBinding: async () => ({ status: "error", message: "unused" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, +}; + +describe("Codex /codex plugins subcommand", () => { + it("lists a configured plugin with its enabled marker and explains the underlying file", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("ON google-calendar"); + expect(result.text).toContain("openclaw.json"); + }); + + it("lists effective disabled status when the global plugin switch is off", async () => { + const io = inMemoryIO( + { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + { enabled: false }, + ); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("OFF google-calendar"); + expect(result.text).toContain("Global codexPlugins.enabled is off"); + }); + + it("enables and disables a configured plugin and reflects the change in subsequent reads", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexPluginsSubcommand( + fakeCtx, + ["disable", "google-calendar"], + io, + ); + expect(disabled.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexPluginsSubcommand(fakeCtx, ["enable", "google-calendar"], io); + expect(enabled.text).toContain("enabled"); + expect(io.currentConfig().enabled).toBe(true); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("rejects enable and disable from non-owner non-admin callers", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.write"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("Only an owner or operator.admin"); + expect(io.current()["google-calendar"]?.enabled).toBe(true); + }); + + it("allows operator.admin gateway callers to enable and disable", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.admin"] }; + + const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io); + expect(result.text).toContain("disabled"); + expect(io.current()["google-calendar"]?.enabled).toBe(false); + }); + + it("escapes configured plugin fields before listing them in chat", async () => { + const io = inMemoryIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar_@team_*name*", + }, + }); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io); + expect(result.text).toContain("google-calendar"); + expect(result.text).toContain("google-calendar_@team_∗name∗"); + expect(result.text).not.toContain("@team"); + expect(result.text).not.toContain("*name*"); + }); + + it("reports when a target plugin is not configured rather than silently no-oping", async () => { + const io = inMemoryIO(); + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable", "chrome_@ops"], io); + expect(result.text).toContain("not configured"); + expect(result.text).toContain("chrome_@ops"); + expect(result.text).not.toContain("@ops"); + }); + + it("returns usage when list, enable, or disable receives the wrong arity", async () => { + const io = inMemoryIO(); + const listResult = await handleCodexPluginsSubcommand(fakeCtx, ["list", "chrome"], io); + expect(listResult.text).toContain("Usage: /codex plugins list"); + + const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io); + expect(result.text).toContain("Usage: /codex plugins disable "); + expect(result.presentation).toBeUndefined(); + + const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io); + expect(enableResult.text).toContain("Usage: /codex plugins enable "); + expect(enableResult.presentation).toBeUndefined(); + + const extraResult = await handleCodexPluginsSubcommand( + fakeCtx, + ["enable", "google-calendar", "extra"], + io, + ); + expect(extraResult.text).toContain("Usage: /codex plugins enable "); + }); +}); diff --git a/extensions/codex/src/command-plugins-management.ts b/extensions/codex/src/command-plugins-management.ts new file mode 100644 index 00000000000..1a53942783b --- /dev/null +++ b/extensions/codex/src/command-plugins-management.ts @@ -0,0 +1,137 @@ +import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; +import { formatCodexDisplayText } from "./command-formatters.js"; + +/** + * Lightweight read/write surface over the Openclaw config file. Plugged in by + * the command registration site so this module stays decoupled from the + * concrete `mutateConfigFile` import in tests. + */ +export type CodexPluginsManagementIO = { + readConfig: () => Promise<{ + enabled?: boolean; + plugins?: Record; + }>; + mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise; +}; + +export type CodexPluginConfigEntry = { + enabled?: boolean; + marketplaceName?: string; + pluginName?: string; + allow_destructive_actions?: boolean; +}; + +export type CodexPluginsConfigBlock = { + enabled?: boolean; + plugins?: Record; +}; + +// Plugin lifecycle changes (enable/disable) write to openclaw.json +// synchronously. The Codex app-server picks up the new policy when the next +// thread starts; in-flight conversations keep the old policy until /new or +// /reset. A full gateway restart is NOT needed. +const POLICY_REFRESH_HINT = + "New Codex conversations pick this up automatically. Use /new or /reset to refresh the current one."; + +export async function handleCodexPluginsSubcommand( + ctx: PluginCommandContext, + rest: string[], + io: CodexPluginsManagementIO, +): Promise { + const [verb = "list", ...args] = rest; + const normalized = verb.toLowerCase(); + + if (normalized === "list") { + if (args.length > 0) { + return { text: "Usage: /codex plugins list" }; + } + const current = await io.readConfig(); + return { + text: formatPluginList(current.plugins ?? {}, { globalEnabled: current.enabled === true }), + }; + } + + const target = args[0]; + if (normalized === "enable" || normalized === "disable") { + if (!target || args.length > 1) { + return { text: `Usage: /codex plugins ${normalized} ` }; + } + if (!canMutateCodexPlugins(ctx)) { + return { + text: `Only an owner or operator.admin gateway client can run /codex plugins ${normalized}.`, + }; + } + const wantEnabled = normalized === "enable"; + const current = (await io.readConfig()).plugins ?? {}; + if (!current[target]) { + return { + text: `Codex sub-plugin '${formatCodexDisplayText(target)}' is not configured. Run '/codex plugins list' to see configured plugins.`, + }; + } + await io.mutate((block) => { + if (wantEnabled) { + block.enabled = true; + } + block.plugins ??= {}; + block.plugins[target] = { ...block.plugins[target], enabled: wantEnabled }; + }); + return { + text: `${formatCodexDisplayText(target)}: ${wantEnabled ? "enabled" : "disabled"} in openclaw.json. ${POLICY_REFRESH_HINT}`, + }; + } + + return { + text: `Unknown /codex plugins subcommand: ${formatCodexDisplayText(verb)}\n\n${buildPluginsHelp()}`, + }; +} + +function canMutateCodexPlugins(ctx: PluginCommandContext): boolean { + if (ctx.senderIsOwner === true) { + return true; + } + return ctx.gatewayClientScopes?.includes("operator.admin") === true; +} + +export function buildPluginsHelp(): string { + return [ + "Codex sub-plugin management (writes only to ~/.openclaw/openclaw.json, never to ~/.codex/config.toml):", + "- /codex plugins (alias for list)", + "- /codex plugins list show all configured Codex sub-plugins", + "- /codex plugins enable enable a configured sub-plugin", + "- /codex plugins disable disable a configured sub-plugin", + ].join("\n"); +} + +export function formatPluginList( + plugins: Record, + options: { globalEnabled?: boolean } = {}, +): string { + const globalEnabled = options.globalEnabled === true; + const keys = Object.keys(plugins).toSorted(); + if (keys.length === 0) { + return "No Codex sub-plugins configured under plugins.entries.codex.config.codexPlugins.plugins"; + } + const rows = keys.map((key) => { + const entry = plugins[key] ?? {}; + const state = globalEnabled && entry.enabled !== false ? "ON " : "OFF"; + const displayKey = formatCodexDisplayText(key); + const pluginName = formatCodexDisplayText(entry.pluginName ?? key); + const marketplace = formatCodexDisplayText(entry.marketplaceName ?? "?"); + return { displayKey, state, pluginName, marketplace }; + }); + const keyW = Math.max(...rows.map((r) => r.displayKey.length)); + const pluginW = Math.max(...rows.map((r) => r.pluginName.length)); + return [ + "Codex sub-plugins in Openclaw config (~/.openclaw/openclaw.json):", + "", + ...rows.map( + (r) => + ` ${r.state} ${r.displayKey.padEnd(keyW)} ${r.pluginName.padEnd(pluginW)} [${r.marketplace}]`, + ), + "", + ...(globalEnabled + ? [] + : ["Global codexPlugins.enabled is off; configured sub-plugins are inactive.", ""]), + "New Codex conversations pick up policy changes automatically; /new or /reset to refresh the current one.", + ].join("\n"); +} diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 08428d81880..d767e6b91fc 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -21,6 +21,11 @@ import { resetCodexDiagnosticsFeedbackStateForTests, type CodexCommandDeps, } from "./command-handlers.js"; +import type { + CodexPluginsConfigBlock, + CodexPluginConfigEntry, + CodexPluginsManagementIO, +} from "./command-plugins-management.js"; import { handleCodexCommand } from "./commands.js"; let tempDir: string; @@ -73,6 +78,27 @@ function createDeps(overrides: Partial = {}): Partial = {}, + options: { enabled?: boolean } = { enabled: true }, +): CodexPluginsManagementIO & { + current: () => Record; + currentConfig: () => CodexPluginsConfigBlock; +} { + const store: CodexPluginsConfigBlock = { + enabled: options.enabled, + plugins: JSON.parse(JSON.stringify(initial)), + }; + return { + current: () => JSON.parse(JSON.stringify(store.plugins ?? {})), + currentConfig: () => JSON.parse(JSON.stringify(store)), + readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))), + mutate: async (update) => { + update(store); + }, + }; +} + function readDiagnosticsConfirmationToken( result: PluginCommandResult, commandPrefix = "/codex diagnostics", @@ -216,6 +242,46 @@ describe("codex command", () => { expect(result.text).not.toContain("<@U123>"); }); + it("lists Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const result = await handleCodexCommand(createContext("plugins list"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + + expectResultTextContains(result, "ON google-calendar"); + expectResultTextContains(result, "openclaw.json"); + }); + + it("enables and disables Codex sub-plugins through the /codex plugins command surface", async () => { + const codexPluginsManagementIo = inMemoryCodexPluginsIO({ + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }); + + const disabled = await handleCodexCommand(createContext("plugins disable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(disabled, "google-calendar: disabled in openclaw.json"); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(false); + + const enabled = await handleCodexCommand(createContext("plugins enable google-calendar"), { + deps: createDeps({ codexPluginsManagementIo }), + }); + expectResultTextContains(enabled, "google-calendar: enabled in openclaw.json"); + expect(codexPluginsManagementIo.currentConfig().enabled).toBe(true); + expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(true); + }); + it("attaches the current session to an existing Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: unknown }> = [];