mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 11:54:06 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -320,6 +320,7 @@ export function buildHelp(): string {
|
||||
"- /codex account",
|
||||
"- /codex mcp",
|
||||
"- /codex skills",
|
||||
"- /codex plugins [list|enable|disable]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
172
extensions/codex/src/command-plugins-management.test.ts
Normal file
172
extensions/codex/src/command-plugins-management.test.ts
Normal 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>");
|
||||
});
|
||||
});
|
||||
137
extensions/codex/src/command-plugins-management.ts
Normal file
137
extensions/codex/src/command-plugins-management.ts
Normal 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");
|
||||
}
|
||||
@@ -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 }> = [];
|
||||
|
||||
Reference in New Issue
Block a user