From 4c40686f9e7060f68358b21bb2e57d793a7aee92 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 03:25:26 -0700 Subject: [PATCH] fix(plugins): trust official Codex package commands --- CHANGELOG.md | 1 + .../contracts/host-hooks.contract.test.ts | 104 ++++++++++++++++++ src/plugins/loader.ts | 4 + src/plugins/registry-types.ts | 1 + src/plugins/registry.ts | 14 ++- 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e84b02ce0..e76933d2d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/commands: allow the official ClawHub Codex plugin package to keep reserved `/codex` command ownership, matching the existing npm-managed Codex package behavior. Thanks @vincentkoc. - Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc. - fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987. - Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987. diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index 33f4ff51fed..acdfac4be56 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -161,6 +161,110 @@ describe("host-hook fixture plugin contract", () => { ); }); + it("allows the official ClawHub Codex plugin to keep /codex command ownership", () => { + const { config, registry } = createPluginRegistryFixture(); + const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex"); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "codex", + name: "Codex", + packageName: "@openclaw/codex", + origin: "global", + rootDir: codexRoot, + source: path.join(codexRoot, "dist", "index.js"), + }), + register(api) { + api.registerCommand({ + name: "codex", + description: "Official ClawHub Codex command", + ownership: "reserved", + handler: async () => ({ text: "ok" }), + }); + }, + }); + + expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]); + expect(registry.registry.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "codex", + message: expect.stringContaining("only bundled plugins can claim reserved command"), + }), + ]), + ); + }); + + it("rejects non-official global Codex plugins from /codex command ownership", () => { + const { config, registry } = createPluginRegistryFixture(); + const codexRoot = path.join("/tmp", ".openclaw", "extensions", "codex"); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "codex", + name: "Codex", + origin: "global", + rootDir: codexRoot, + source: path.join(codexRoot, "dist", "index.js"), + }), + register(api) { + api.registerCommand({ + name: "codex", + description: "Impostor Codex command", + ownership: "reserved", + handler: async () => ({ text: "no" }), + }); + }, + }); + + expect(registry.registry.commands).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "codex", + message: expect.stringContaining("only bundled plugins can claim reserved command"), + }), + ]), + ); + }); + + it("rejects workspace Codex plugins that spoof the official package name", () => { + const { config, registry } = createPluginRegistryFixture(); + const codexRoot = path.join("/tmp", "workspace", "codex"); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "codex", + name: "Codex", + packageName: "@openclaw/codex", + origin: "workspace", + rootDir: codexRoot, + source: path.join(codexRoot, "dist", "index.js"), + }), + register(api) { + api.registerCommand({ + name: "codex", + description: "Workspace Codex command", + ownership: "reserved", + handler: async () => ({ text: "no" }), + }); + }, + }); + + expect(registry.registry.commands).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "codex", + message: expect.stringContaining("only bundled plugins can claim reserved command"), + }), + ]), + ); + }); + it("rejects reserved command ownership for non-reserved bundled command names", () => { const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 47464178382..714d837b8bc 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1731,6 +1731,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + packageName: manifestRecord.packageName, format: manifestRecord.format, bundleFormat: manifestRecord.bundleFormat, bundleCapabilities: manifestRecord.bundleCapabilities, @@ -1768,6 +1769,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + packageName: manifestRecord.packageName, format: manifestRecord.format, bundleFormat: manifestRecord.bundleFormat, bundleCapabilities: manifestRecord.bundleCapabilities, @@ -2553,6 +2555,7 @@ export async function loadOpenClawPluginCliRegistry( name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + packageName: manifestRecord.packageName, format: manifestRecord.format, bundleFormat: manifestRecord.bundleFormat, bundleCapabilities: manifestRecord.bundleCapabilities, @@ -2590,6 +2593,7 @@ export async function loadOpenClawPluginCliRegistry( name: manifestRecord.name ?? pluginId, description: manifestRecord.description, version: manifestRecord.version, + packageName: manifestRecord.packageName, format: manifestRecord.format, bundleFormat: manifestRecord.bundleFormat, bundleCapabilities: manifestRecord.bundleCapabilities, diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 2c6a68b344f..b8820c72481 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -327,6 +327,7 @@ export type PluginRecord = { id: string; name: string; version?: string; + packageName?: string; description?: string; format?: PluginFormat; bundleFormat?: PluginBundleFormat; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c0e3845026b..839e71501ad 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -271,10 +271,18 @@ export function resolvePluginPath(input: string, rootDir: string | undefined): s return rootDir ? path.resolve(rootDir, trimmed) : resolveUserPath(input); } -function isOfficialNpmCodexPluginRecord(record: Pick) { +function isOfficialCodexPluginRecord( + record: Pick, +) { if (record.id !== "codex") { return false; } + if (record.origin !== "global") { + return false; + } + if (record.packageName === "@openclaw/codex") { + return true; + } const sourcePath = path .normalize(record.rootDir ?? record.source) .split(path.sep) @@ -283,9 +291,9 @@ function isOfficialNpmCodexPluginRecord(record: Pick, + record: Pick, ) { - return record.origin === "bundled" || isOfficialNpmCodexPluginRecord(record); + return record.origin === "bundled" || isOfficialCodexPluginRecord(record); } const ACTIVE_PLUGIN_HOOK_REGISTRATIONS_KEY = Symbol.for("openclaw.activePluginHookRegistrations");