Skip to content

Commit 9b97e1e

Browse files
authored
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
1 parent 94d8391 commit 9b97e1e

9 files changed

Lines changed: 487 additions & 7 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
5858
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
5959
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
6060
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
61+
- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand.
6162
- 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.
6263
- 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.
6364
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.

docs/plugins/codex-harness.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ Common command routing:
202202
| Attach the current chat | `/codex bind [--cwd <path>]` |
203203
| Resume an existing Codex thread | `/codex resume <thread-id>` |
204204
| List or filter Codex threads | `/codex threads [filter]` |
205+
| List native Codex plugins | `/codex plugins list` |
206+
| Enable or disable a configured native Codex plugin | `/codex plugins enable <name>`, `/codex plugins disable <name>` |
205207
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
206208
| Send Codex feedback only | `/codex diagnostics [note]` |
207209
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |

docs/plugins/codex-native-plugins.md

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,35 @@ config looks like this:
8181
}
8282
```
8383

84-
After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so
85-
future Codex harness sessions start with the updated app set.
84+
After changing `codexPlugins`, new Codex conversations pick up the updated app
85+
set automatically. Use `/new` or `/reset` to refresh the current conversation.
86+
A gateway restart is not required for plugin enable or disable changes.
87+
88+
## Manage plugins from chat
89+
90+
Use `/codex plugins` when you want to inspect or change configured native Codex
91+
plugins from the same chat where you operate the Codex harness:
92+
93+
```text
94+
/codex plugins
95+
/codex plugins list
96+
/codex plugins disable google-calendar
97+
/codex plugins enable google-calendar
98+
```
99+
100+
`/codex plugins` is an alias for `/codex plugins list`. The list output shows
101+
the configured plugin keys, on/off state, Codex plugin name, and marketplace
102+
from `plugins.entries.codex.config.codexPlugins.plugins`.
103+
104+
`enable` and `disable` write only to OpenClaw config at
105+
`~/.openclaw/openclaw.json`; they do not edit `~/.codex/config.toml` or install
106+
new Codex plugins. Only the owner or a gateway client with the
107+
`operator.admin` scope can change plugin state.
108+
109+
Enabling a configured plugin also turns on the global
110+
`codexPlugins.enabled` switch. If the plugin was written disabled because
111+
migration returned `auth_required`, reauthorize the app in Codex before enabling
112+
it in OpenClaw.
86113

87114
## How native plugin setup works
88115

@@ -110,7 +137,10 @@ check after migration. Codex harness session setup then computes a restrictive
110137
thread app config for the enabled and accessible plugin apps.
111138

112139
Thread app config is computed when OpenClaw establishes a Codex harness session
113-
or replaces a stale Codex thread binding. It is not recomputed on every turn.
140+
or replaces a stale Codex thread binding. It is not recomputed on every turn, so
141+
`/codex plugins enable` and `/codex plugins disable` affect new Codex
142+
conversations. Use `/new` or `/reset` when the current conversation should pick
143+
up the updated app set.
114144

115145
## V1 support boundary
116146

@@ -228,10 +258,10 @@ apps until ownership and readiness are known.
228258
**`app_ownership_ambiguous`:** app inventory only matched by display name, so
229259
the app is not exposed to the Codex thread.
230260

231-
**Config changed but the agent cannot see the plugin:** use `/new`, `/reset`, or
232-
restart the gateway. Existing Codex thread bindings keep the app config they
233-
started with until OpenClaw establishes a new harness session or replaces a
234-
stale binding.
261+
**Config changed but the agent cannot see the plugin:** use `/codex plugins
262+
list` to confirm the configured state, then use `/new` or `/reset`. Existing
263+
Codex thread bindings keep the app config they started with until OpenClaw
264+
establishes a new harness session or replaces a stale binding.
235265

236266
**Destructive action is declined:** check the global and per-plugin
237267
`allow_destructive_actions` values. Even when policy is true, unsafe elicitation

extensions/codex/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
2+
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
23
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
34
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
45
import { createCodexAppServerAgentHarness } from "./harness.js";
56
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
67
import { buildCodexProvider } from "./provider.js";
8+
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
79
import { createCodexCommand } from "./src/commands.js";
810
import {
911
handleCodexConversationBindingResolved,
@@ -53,6 +55,60 @@ export default definePluginEntry({
5355
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
5456
resolveCodexCliSessionForBindingOnNode: (params) =>
5557
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
58+
codexPluginsManagementIo: {
59+
readConfig: () => {
60+
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
61+
const plugins = (current as Record<string, unknown>).plugins;
62+
if (!plugins || typeof plugins !== "object") {
63+
return Promise.resolve({});
64+
}
65+
const entries = (plugins as Record<string, unknown>).entries;
66+
if (!entries || typeof entries !== "object") {
67+
return Promise.resolve({});
68+
}
69+
const codexEntry = (entries as Record<string, unknown>).codex;
70+
if (!codexEntry || typeof codexEntry !== "object") {
71+
return Promise.resolve({});
72+
}
73+
const config = (codexEntry as Record<string, unknown>).config;
74+
if (!config || typeof config !== "object") {
75+
return Promise.resolve({});
76+
}
77+
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
78+
if (!codexPlugins || typeof codexPlugins !== "object") {
79+
return Promise.resolve({});
80+
}
81+
const declared = (codexPlugins as Record<string, unknown>).plugins;
82+
if (!declared || typeof declared !== "object") {
83+
return Promise.resolve({
84+
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
85+
});
86+
}
87+
return Promise.resolve({
88+
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
89+
plugins: declared as Record<string, never>,
90+
});
91+
},
92+
mutate: async (update) => {
93+
await mutateConfigFile({
94+
mutate: (draft) => {
95+
const root = draft as Record<string, unknown>;
96+
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
97+
const pluginsBlock = root.plugins as Record<string, unknown>;
98+
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
99+
const entries = pluginsBlock.entries as Record<string, unknown>;
100+
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
101+
const codexEntry = entries.codex as Record<string, unknown>;
102+
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
103+
const config = codexEntry.config as Record<string, unknown>;
104+
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
105+
const codexPlugins = config.codexPlugins as Record<string, unknown>;
106+
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
107+
update(codexPlugins as CodexPluginsConfigBlock);
108+
},
109+
});
110+
},
111+
},
56112
},
57113
}),
58114
);

extensions/codex/src/command-formatters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ export function buildHelp(): string {
320320
"- /codex account",
321321
"- /codex mcp",
322322
"- /codex skills",
323+
"- /codex plugins [list|enable|disable]",
323324
].join("\n");
324325
}
325326

extensions/codex/src/command-handlers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import {
2828
formatThreads,
2929
readString,
3030
} from "./command-formatters.js";
31+
import {
32+
handleCodexPluginsSubcommand,
33+
type CodexPluginsManagementIO,
34+
} from "./command-plugins-management.js";
3135
import {
3236
codexControlRequest,
3337
readCodexStatusProbes,
@@ -80,6 +84,7 @@ export type CodexCommandDeps = {
8084
stopCodexConversationTurn: typeof stopCodexConversationTurn;
8185
listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn;
8286
resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn;
87+
codexPluginsManagementIo?: CodexPluginsManagementIO;
8388
};
8489

8590
type CodexControlRequestFn = (
@@ -228,6 +233,16 @@ export async function handleCodexSubcommand(
228233
if (normalized === "help") {
229234
return { text: buildHelp() };
230235
}
236+
if (normalized === "plugins") {
237+
if (!deps.codexPluginsManagementIo) {
238+
return {
239+
text:
240+
"Codex sub-plugin management is not wired up (codexPluginsManagementIo dep is undefined). " +
241+
"Edit ~/.openclaw/openclaw.json or use `openclaw config patch` until the runtime exposes the IO.",
242+
};
243+
}
244+
return await handleCodexPluginsSubcommand(ctx, rest, deps.codexPluginsManagementIo);
245+
}
231246
if (normalized === "status") {
232247
if (rest.length > 0) {
233248
return { text: "Usage: /codex status" };
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
handleCodexPluginsSubcommand,
5+
type CodexPluginsConfigBlock,
6+
type CodexPluginConfigEntry,
7+
type CodexPluginsManagementIO,
8+
} from "./command-plugins-management.js";
9+
10+
function inMemoryIO(
11+
initial: Record<string, CodexPluginConfigEntry> = {},
12+
options: { enabled?: boolean } = { enabled: true },
13+
): CodexPluginsManagementIO & {
14+
current: () => Record<string, CodexPluginConfigEntry>;
15+
currentConfig: () => CodexPluginsConfigBlock;
16+
} {
17+
const store: CodexPluginsConfigBlock = {
18+
enabled: options.enabled,
19+
plugins: JSON.parse(JSON.stringify(initial)),
20+
};
21+
return {
22+
current: () => JSON.parse(JSON.stringify(store.plugins ?? {})),
23+
currentConfig: () => JSON.parse(JSON.stringify(store)),
24+
readConfig: () => Promise.resolve(JSON.parse(JSON.stringify(store))),
25+
mutate: async (update) => {
26+
update(store);
27+
},
28+
};
29+
}
30+
31+
const fakeCtx: PluginCommandContext = {
32+
args: "",
33+
config: {},
34+
channel: "test",
35+
isAuthorizedSender: true,
36+
senderIsOwner: true,
37+
commandBody: "/codex plugins",
38+
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
39+
detachConversationBinding: async () => ({ removed: false }),
40+
getCurrentConversationBinding: async () => null,
41+
};
42+
43+
describe("Codex /codex plugins subcommand", () => {
44+
it("lists a configured plugin with its enabled marker and explains the underlying file", async () => {
45+
const io = inMemoryIO({
46+
"google-calendar": {
47+
enabled: true,
48+
marketplaceName: "openai-curated",
49+
pluginName: "google-calendar",
50+
},
51+
});
52+
53+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io);
54+
expect(result.text).toContain("ON google-calendar");
55+
expect(result.text).toContain("openclaw.json");
56+
});
57+
58+
it("lists effective disabled status when the global plugin switch is off", async () => {
59+
const io = inMemoryIO(
60+
{
61+
"google-calendar": {
62+
enabled: true,
63+
marketplaceName: "openai-curated",
64+
pluginName: "google-calendar",
65+
},
66+
},
67+
{ enabled: false },
68+
);
69+
70+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io);
71+
expect(result.text).toContain("OFF google-calendar");
72+
expect(result.text).toContain("Global codexPlugins.enabled is off");
73+
});
74+
75+
it("enables and disables a configured plugin and reflects the change in subsequent reads", async () => {
76+
const io = inMemoryIO({
77+
"google-calendar": {
78+
enabled: true,
79+
marketplaceName: "openai-curated",
80+
pluginName: "google-calendar",
81+
},
82+
});
83+
84+
const disabled = await handleCodexPluginsSubcommand(
85+
fakeCtx,
86+
["disable", "google-calendar"],
87+
io,
88+
);
89+
expect(disabled.text).toContain("disabled");
90+
expect(io.current()["google-calendar"]?.enabled).toBe(false);
91+
92+
const enabled = await handleCodexPluginsSubcommand(fakeCtx, ["enable", "google-calendar"], io);
93+
expect(enabled.text).toContain("enabled");
94+
expect(io.currentConfig().enabled).toBe(true);
95+
expect(io.current()["google-calendar"]?.enabled).toBe(true);
96+
});
97+
98+
it("rejects enable and disable from non-owner non-admin callers", async () => {
99+
const io = inMemoryIO({
100+
"google-calendar": {
101+
enabled: true,
102+
marketplaceName: "openai-curated",
103+
pluginName: "google-calendar",
104+
},
105+
});
106+
const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.write"] };
107+
108+
const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io);
109+
expect(result.text).toContain("Only an owner or operator.admin");
110+
expect(io.current()["google-calendar"]?.enabled).toBe(true);
111+
});
112+
113+
it("allows operator.admin gateway callers to enable and disable", async () => {
114+
const io = inMemoryIO({
115+
"google-calendar": {
116+
enabled: true,
117+
marketplaceName: "openai-curated",
118+
pluginName: "google-calendar",
119+
},
120+
});
121+
const ctx = { ...fakeCtx, senderIsOwner: false, gatewayClientScopes: ["operator.admin"] };
122+
123+
const result = await handleCodexPluginsSubcommand(ctx, ["disable", "google-calendar"], io);
124+
expect(result.text).toContain("disabled");
125+
expect(io.current()["google-calendar"]?.enabled).toBe(false);
126+
});
127+
128+
it("escapes configured plugin fields before listing them in chat", async () => {
129+
const io = inMemoryIO({
130+
"google-calendar": {
131+
enabled: true,
132+
marketplaceName: "openai-curated",
133+
pluginName: "google-calendar_@team_*name*",
134+
},
135+
});
136+
137+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["list"], io);
138+
expect(result.text).toContain("google-calendar");
139+
expect(result.text).toContain("google-calendar_@team_∗name∗");
140+
expect(result.text).not.toContain("@team");
141+
expect(result.text).not.toContain("*name*");
142+
});
143+
144+
it("reports when a target plugin is not configured rather than silently no-oping", async () => {
145+
const io = inMemoryIO();
146+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable", "chrome_@ops"], io);
147+
expect(result.text).toContain("not configured");
148+
expect(result.text).toContain("chrome_@ops");
149+
expect(result.text).not.toContain("@ops");
150+
});
151+
152+
it("returns usage when list, enable, or disable receives the wrong arity", async () => {
153+
const io = inMemoryIO();
154+
const listResult = await handleCodexPluginsSubcommand(fakeCtx, ["list", "chrome"], io);
155+
expect(listResult.text).toContain("Usage: /codex plugins list");
156+
157+
const result = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io);
158+
expect(result.text).toContain("Usage: /codex plugins disable <name>");
159+
expect(result.presentation).toBeUndefined();
160+
161+
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
162+
expect(enableResult.text).toContain("Usage: /codex plugins enable <name>");
163+
expect(enableResult.presentation).toBeUndefined();
164+
165+
const extraResult = await handleCodexPluginsSubcommand(
166+
fakeCtx,
167+
["enable", "google-calendar", "extra"],
168+
io,
169+
);
170+
expect(extraResult.text).toContain("Usage: /codex plugins enable <name>");
171+
});
172+
});

0 commit comments

Comments
 (0)