feat(codex): add plugin list enable disable commands (#83293)

* feat(codex): add plugin enable disable list commands

* fix(codex): escape plugin management output

* test(codex): narrow plugin command coverage

* fix(codex): gate plugin management writes

* test(codex): type command plugin context

* docs(codex): document plugin management commands
This commit is contained in:
Kevin Lin
2026-05-19 11:39:50 -07:00
committed by GitHub
parent 94d8391c03
commit 9b97e1ef2f
9 changed files with 487 additions and 7 deletions

View File

@@ -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.

View File

@@ -202,6 +202,8 @@ Common command routing:
| Attach the current chat | `/codex bind [--cwd <path>]` |
| Resume an existing Codex thread | `/codex resume <thread-id>` |
| 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 <name>`, `/codex plugins disable <name>` |
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
| Send Codex feedback only | `/codex diagnostics [note]` |
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |

View File

@@ -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

View File

@@ -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<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
mutate: async (update) => {
await mutateConfigFile({
mutate: (draft) => {
const root = draft as Record<string, unknown>;
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
},
},
},
}),
);

View File

@@ -320,6 +320,7 @@ export function buildHelp(): string {
"- /codex account",
"- /codex mcp",
"- /codex skills",
"- /codex plugins [list|enable|disable]",
].join("\n");
}

View File

@@ -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" };

View File

@@ -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<string, CodexPluginConfigEntry> = {},
options: { enabled?: boolean } = { enabled: true },
): CodexPluginsManagementIO & {
current: () => Record<string, CodexPluginConfigEntry>;
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 <name>");
expect(result.presentation).toBeUndefined();
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
expect(enableResult.text).toContain("Usage: /codex plugins enable <name>");
expect(enableResult.presentation).toBeUndefined();
const extraResult = await handleCodexPluginsSubcommand(
fakeCtx,
["enable", "google-calendar", "extra"],
io,
);
expect(extraResult.text).toContain("Usage: /codex plugins enable <name>");
});
});

View File

@@ -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<string, CodexPluginConfigEntry>;
}>;
mutate: (update: (block: CodexPluginsConfigBlock) => void) => Promise<void>;
};
export type CodexPluginConfigEntry = {
enabled?: boolean;
marketplaceName?: string;
pluginName?: string;
allow_destructive_actions?: boolean;
};
export type CodexPluginsConfigBlock = {
enabled?: boolean;
plugins?: Record<string, CodexPluginConfigEntry>;
};
// 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<PluginCommandResult> {
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} <name>` };
}
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 <name> enable a configured sub-plugin",
"- /codex plugins disable <name> disable a configured sub-plugin",
].join("\n");
}
export function formatPluginList(
plugins: Record<string, CodexPluginConfigEntry>,
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");
}

View File

@@ -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<CodexCommandDeps> = {}): Partial<CodexCom
};
}
function inMemoryCodexPluginsIO(
initial: Record<string, CodexPluginConfigEntry> = {},
options: { enabled?: boolean } = { enabled: true },
): CodexPluginsManagementIO & {
current: () => Record<string, CodexPluginConfigEntry>;
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 }> = [];