diff --git a/.github/labeler.yml b/.github/labeler.yml index d53da58026f3..1ca16e86995f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -709,10 +709,6 @@ - changed-files: - any-glob-to-any-file: - "extensions/sglang/**" -"extensions: thread-ownership": - - changed-files: - - any-glob-to-any-file: - - "extensions/thread-ownership/**" "extensions: vllm": - changed-files: - any-glob-to-any-file: diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index e77c84f0aae2..feff4684dc8c 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -16,6 +16,12 @@ Talk mode covers five runtime shapes: Native Talk is a continuous loop: listen for speech, send the transcript to the model through the active session, wait for the response, then speak it via the configured Talk provider (`talk.speak`). +## Choose a Talk voice from chat + +After setting `talk.provider` and the matching `talk.providers.` configuration, use `/voice status` to inspect the active provider and voice, `/voice list [limit]` to list its available voices, and `/voice set ` to save a provider-scoped selection. Discord exposes the same command natively as `/talkvoice`. + +Status and list are read-only. Setting a voice requires the message-channel owner or a Gateway client with `operator.admin`. Configuration, provider lookup, unknown-voice, and permission failures are returned visibly in chat. A masked API-key value in `/voice status` describes config only; it does not verify credential availability. + Client-owned realtime Talk normally forwards provider tool calls through `talk.client.toolCall` instead of calling `chat.send` directly. GPT-Live WebRTC sessions delegate on a Gateway-owned sideband, and the Gateway binds each delegation to the browser or Gateway-relay Talk session that owns it. Backend WebSocket bridges use the normal relay consult path. While a realtime consult is active, clients can call `talk.client.steer` or `talk.session.steer` to classify spoken input as `status`, `steer`, `cancel`, or `followup`; this includes GPT-Live delegations. Accepted steering queues into the active embedded run; rejected steering returns a reason such as `no_active_run`, `not_streaming`, or `compacting`. A newer GPT-Live spoken task also supersedes the running delegation. Thin audio clients can request `gateway-control-v1` in diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 94c608528c99..5636b08207da 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -9,8 +9,9 @@ title: "Plugin inventory" # Plugin inventory -This page is generated from `extensions/*/package.json`, `openclaw.plugin.json`, -and the root npm package `files` exclusions. Regenerate it with: +This page is generated from top-level `extensions/*/openclaw.plugin.json` +manifests and the root npm package `files` exclusions. Optional `package.json` +metadata enriches package and distribution details. Regenerate it with: ```bash pnpm plugins:inventory:gen @@ -51,7 +52,9 @@ Each entry lists the package, distribution route, and description. ## Core npm package -55 plugins +58 plugins + +- **[active-memory](/plugins/reference/active-memory)** (`openclaw`) - included in OpenClaw. Runs bounded pre-reply memory retrieval and implements per-agent Remember across conversations for eligible private conversations. - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -79,6 +82,8 @@ Each entry lists the package, distribution route, and description. - **[deepgram](/plugins/reference/deepgram)** (`@openclaw/deepgram-provider`) - included in OpenClaw. Adds media understanding provider support. Adds realtime transcription provider support. +- **[device-pair](/plugins/reference/device-pair)** (`openclaw`) - included in OpenClaw. Generate setup codes and approve device pairing requests. + - **[document-extract](/plugins/reference/document-extract)** (`@openclaw/document-extract-plugin`) - included in OpenClaw. Extract text and fallback page images from local document attachments. - **[elevenlabs](/plugins/reference/elevenlabs)** (`@openclaw/elevenlabs-speech`) - included in OpenClaw. Adds media understanding provider support. Adds realtime transcription provider support. Adds text-to-speech provider support. @@ -145,6 +150,8 @@ Each entry lists the package, distribution route, and description. - **[sglang](/plugins/reference/sglang)** (`@openclaw/sglang-provider`) - included in OpenClaw. Adds SGLang model provider support to OpenClaw. +- **[talk-voice](/plugins/reference/talk-voice)** (`openclaw`) - included in OpenClaw. Manage Talk voice selection (list/set). + - **[telegram](/plugins/reference/telegram)** (`@openclaw/telegram`) - included in OpenClaw. Adds the Telegram channel surface for sending and receiving OpenClaw messages. - **[together](/plugins/reference/together)** (`@openclaw/together-provider`) - included in OpenClaw. Adds Together model provider support to OpenClaw. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index de1891a661d1..f1de05e51b50 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -8,12 +8,13 @@ title: "Plugin reference" # Plugin reference -This page is generated from `extensions/*/package.json` and -`openclaw.plugin.json`. Regenerate it with: +This page is generated from top-level `extensions/*/openclaw.plugin.json` +manifests. Package metadata enriches entries when `package.json` is present. +Regenerate it with: ```bash pnpm plugins:inventory:gen ``` -Use [Plugin inventory](/plugins/plugin-inventory) to browse all 146 +Use [Plugin inventory](/plugins/plugin-inventory) to browse all 149 generated plugin reference pages by distribution, package, and description. diff --git a/docs/plugins/reference/active-memory.md b/docs/plugins/reference/active-memory.md new file mode 100644 index 000000000000..e0f02161b6bc --- /dev/null +++ b/docs/plugins/reference/active-memory.md @@ -0,0 +1,19 @@ +--- +summary: "Runs bounded pre-reply memory retrieval and implements per-agent Remember across conversations for eligible private conversations." +read_when: + - You are installing, configuring, or auditing the active-memory plugin +title: "Active Memory plugin" +--- + +# Active Memory plugin + +Runs bounded pre-reply memory retrieval and implements per-agent Remember across conversations for eligible private conversations. + +## Distribution + +- Package: `openclaw` +- Install route: included in OpenClaw + +## Surface + +plugin diff --git a/docs/plugins/reference/codex.md b/docs/plugins/reference/codex.md index c282a7fe0c55..09c0b448d075 100644 --- a/docs/plugins/reference/codex.md +++ b/docs/plugins/reference/codex.md @@ -16,7 +16,7 @@ Codex app-server harness and native session catalog. ## Surface -contracts: `mediaUnderstandingProviders`, `migrationProviders`, `tools`, `webSearchProviders` +commands: `/codex`; contracts: `mediaUnderstandingProviders`, `migrationProviders`, `tools`, `webSearchProviders` ## Related docs diff --git a/docs/plugins/reference/device-pair.md b/docs/plugins/reference/device-pair.md new file mode 100644 index 000000000000..f412dafd3b8f --- /dev/null +++ b/docs/plugins/reference/device-pair.md @@ -0,0 +1,19 @@ +--- +summary: "Generate setup codes and approve device pairing requests." +read_when: + - You are installing, configuring, or auditing the device-pair plugin +title: "Device Pair plugin" +--- + +# Device Pair plugin + +Generate setup codes and approve device pairing requests. + +## Distribution + +- Package: `openclaw` +- Install route: included in OpenClaw + +## Surface + +commands: `/pair` diff --git a/docs/plugins/reference/memory-core.md b/docs/plugins/reference/memory-core.md index c5217faeeac3..dbf6033d9fb2 100644 --- a/docs/plugins/reference/memory-core.md +++ b/docs/plugins/reference/memory-core.md @@ -16,4 +16,4 @@ Adds agent-callable tools. ## Surface -contracts: `tools` +commands: `/dreaming`; contracts: `tools` diff --git a/docs/plugins/reference/talk-voice.md b/docs/plugins/reference/talk-voice.md new file mode 100644 index 000000000000..0bceb314f5bd --- /dev/null +++ b/docs/plugins/reference/talk-voice.md @@ -0,0 +1,35 @@ +--- +summary: "Manage Talk voice selection (list/set)." +read_when: + - You are installing, configuring, or auditing the talk-voice plugin +title: "Talk Voice plugin" +--- + +# Talk Voice plugin + +Manage Talk voice selection (list/set). + +## Distribution + +- Package: `openclaw` +- Install route: included in OpenClaw + +## Surface + +commands: `/voice` + + + +## Configure a Talk voice from chat + +Set `talk.provider` and configure the matching `talk.providers.` entry before using the command. The active provider must support voice listing. + +- `/voice status` shows the active provider and selected provider-scoped voice ID. The API-key field is only a masked or unset config value; it does not prove that usable credentials are available. +- `/voice list [limit]` lists voices from the active provider. The default limit is 12 and the maximum is 50. +- `/voice set ` resolves a voice by exact ID, exact name, or partial name, then saves it to `talk.providers..voiceId`. + +Discord registers the native command as `/talkvoice`; its subcommands and arguments are the same. Status and list are read-only. Setting a voice requires an owner on a message channel or the `operator.admin` scope for a Gateway client. + +Failures are returned visibly in chat. Missing Talk configuration identifies the required keys; provider lookup errors include the provider error; unknown voices suggest listing available voices; and unauthorized writes state the required permission. + + diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index c0bc8725f144..171fa3d6206c 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -319,13 +319,13 @@ must be in the same identity group. ### Bundled plugin commands -| Command | Description | -| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/dreaming [on\|off\|status\|help]` | Toggle memory dreaming (owner or Gateway admin). See [Dreaming](/concepts/dreaming) | -| `/pair [qr\|status\|pending\|approve\|cleanup\|notify]` | Manage device pairing. See [Pairing](/channels/pairing) | -| `/voice status\|list\|set ` | Manage Talk voice config. Discord native name: `/talkvoice` | -| `/card ...` | Send LINE rich card presets. See [LINE](/channels/line) | -| `/codex ...` | Bind, steer, and inspect the Codex app-server harness (status, threads, resume, model, fast, permissions, compact, review, mcp, skills, and more). See [Codex harness](/plugins/codex-harness) | +| Command | Description | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/dreaming [on\|off\|status\|help]` | Toggle memory dreaming (owner or Gateway admin). See [Dreaming](/concepts/dreaming) | +| `/pair [qr\|status\|pending\|approve\|cleanup\|notify]` | Manage device pairing. See [Pairing](/channels/pairing) | +| [`/voice`](/nodes/talk#choose-a-talk-voice-from-chat) `status\|list\|set ` | Manage Talk voice config. Discord native name: `/talkvoice` | +| `/card ...` | Send LINE rich card presets. See [LINE](/channels/line) | +| `/codex ...` | Bind, steer, and inspect the Codex app-server harness (status, threads, resume, model, fast, permissions, compact, review, mcp, skills, and more). See [Codex harness](/plugins/codex-harness) | QQBot-only: `/bot-ping`, `/bot-version`, `/bot-help`, `/bot-upgrade`, `/bot-logs` diff --git a/extensions/slack/src/outbound-delivery.test.ts b/extensions/slack/src/outbound-delivery.test.ts index 41864fc4a88d..9e63023f4164 100644 --- a/extensions/slack/src/outbound-delivery.test.ts +++ b/extensions/slack/src/outbound-delivery.test.ts @@ -62,7 +62,7 @@ describe("slack outbound shared hook wiring", () => { const handler = vi.fn().mockResolvedValue(undefined); addTestHook({ registry: hookRegistry, - pluginId: "thread-ownership", + pluginId: "test-plugin", hookName: "message_sending", handler: handler as PluginHookRegistration["handler"], }); @@ -125,7 +125,7 @@ describe("slack outbound shared hook wiring", () => { const handler = vi.fn().mockResolvedValue({ cancel: true }); addTestHook({ registry: hookRegistry, - pluginId: "thread-ownership", + pluginId: "test-plugin", hookName: "message_sending", handler: handler as PluginHookRegistration["handler"], }); diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index eafeacf28d34..99af530551b7 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -220,7 +220,7 @@ describe("talk-voice plugin", () => { }); }); - it("writes canonical talk provider config and legacy elevenlabs voice id", async () => { + it("writes only canonical provider-scoped voice config for elevenlabs", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", @@ -241,7 +241,8 @@ describe("talk-voice plugin", () => { afterWrite: { mode: "auto" }, mutate: expect.any(Function), }); - expect(runtime.config.current()).toEqual({ + const updatedConfig = runtime.config.current() as { talk: Record }; + expect(updatedConfig).toEqual({ talk: { provider: "elevenlabs", providers: { @@ -250,9 +251,9 @@ describe("talk-voice plugin", () => { voiceId: "voice-a", }, }, - voiceId: "voice-a", }, }); + expect(Object.hasOwn(updatedConfig.talk, "voiceId")).toBe(false); expect(result).toEqual({ text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a", }); diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 4a89b4a34868..ad1dc0fc67b9 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -237,7 +237,6 @@ export default definePluginEntry({ voiceId: chosen.id, }, }, - ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), }, }; Object.assign(draft, nextConfig); diff --git a/extensions/thread-ownership/api.ts b/extensions/thread-ownership/api.ts deleted file mode 100644 index 99fe1ebd0f6a..000000000000 --- a/extensions/thread-ownership/api.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Thread Ownership API module exposes the plugin public contract. -export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -export { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -export { - fetchWithSsrFGuard, - ssrfPolicyFromDangerouslyAllowPrivateNetwork, -} from "openclaw/plugin-sdk/ssrf-runtime"; diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts deleted file mode 100644 index 1b89a8b2624b..000000000000 --- a/extensions/thread-ownership/index.test.ts +++ /dev/null @@ -1,588 +0,0 @@ -// Thread Ownership tests cover index plugin behavior. -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawPluginApi } from "./api.js"; -import register from "./index.js"; - -describe("thread-ownership plugin", () => { - const hooks: Record = {}; - const requireHook = (name: string): Function => - expectDefined(hooks[name], `thread-ownership ${name} hook registration`); - const fetchMock = vi.fn() as unknown as typeof globalThis.fetch; - let configFile: Record = {}; - const originalSlackForwarderUrl = process.env.SLACK_FORWARDER_URL; - const originalSlackBotUserId = process.env.SLACK_BOT_USER_ID; - const api = { - pluginConfig: {}, - config: { - agents: { - list: [{ id: "test-agent", default: true, identity: { name: "TestBot" } }], - }, - }, - runtime: { - config: { - current: () => configFile, - }, - }, - id: "thread-ownership", - name: "Thread Ownership", - logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, - on: vi.fn((hookName: string, handler: Function) => { - hooks[hookName] = handler; - }), - }; - - function expectOwnershipFetchCall(index: number, url: string, agentId: string) { - const call = vi.mocked(globalThis.fetch).mock.calls[index]; - if (!call) { - throw new Error(`expected ownership fetch call ${index}`); - } - expect(call[0]).toBe(url); - const init = call[1]; - expect(init?.method).toBe("POST"); - expect(init?.body).toBe(JSON.stringify({ agent_id: agentId })); - } - - function requireFirstLogMessage(mock: ReturnType, label: string): string { - const [call] = mock.mock.calls; - if (!call || typeof call[0] !== "string") { - throw new Error(`expected ${label}`); - } - return call[0]; - } - - beforeEach(() => { - vi.clearAllMocks(); - for (const key of Object.keys(hooks)) { - delete hooks[key]; - } - api.pluginConfig = {}; - configFile = { - agents: api.config.agents, - }; - - process.env.SLACK_FORWARDER_URL = "http://localhost:8750"; - process.env.SLACK_BOT_USER_ID = "U999"; - - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - if (originalSlackForwarderUrl === undefined) { - delete process.env.SLACK_FORWARDER_URL; - } else { - process.env.SLACK_FORWARDER_URL = originalSlackForwarderUrl; - } - if (originalSlackBotUserId === undefined) { - delete process.env.SLACK_BOT_USER_ID; - } else { - process.env.SLACK_BOT_USER_ID = originalSlackBotUserId; - } - vi.restoreAllMocks(); - }); - - describe("message_sending", () => { - beforeEach(() => { - register.register(api as unknown as OpenClawPluginApi); - }); - - async function sendSlackThreadMessage() { - return await requireHook("message_sending")( - { content: "hello", replyToId: "1234.5678", metadata: { channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); - } - - it("allows non-slack channels", async () => { - const result = await requireHook("message_sending")( - { content: "hello", replyToId: "1234.5678", metadata: { channelId: "C123" }, to: "C123" }, - { channelId: "discord", conversationId: "C123" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("allows top-level messages (no threadTs)", async () => { - const result = await requireHook("message_sending")( - { content: "hello", metadata: {}, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("fails open when Slack thread routing has no canonical conversation id", async () => { - const result = await requireHook("message_sending")( - { content: "hello", replyToId: "1234.5678", metadata: {}, to: "" }, - { channelId: "slack", conversationId: "" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("claims ownership successfully", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await sendSlackThreadMessage(); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("uses the default forwarder URL when the env override is blank", async () => { - process.env.SLACK_FORWARDER_URL = " "; - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await sendSlackThreadMessage(); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://slack-forwarder:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("keeps live plugin config ahead of the env override", async () => { - configFile = { - ...configFile, - plugins: { - entries: { - "thread-ownership": { - config: { - forwarderUrl: "http://config-forwarder:8750", - }, - }, - }, - }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await sendSlackThreadMessage(); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://config-forwarder:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("prefers shared conversationId over non-canonical Slack target shapes", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await requireHook("message_sending")( - { - content: "hello", - replyToId: "1234.5678", - to: "channel:C123", - }, - { channelId: "slack", conversationId: "C123" }, - ); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("canonicalizes non-canonical Slack targets when shared conversationId is missing", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await requireHook("message_sending")( - { - content: "hello", - replyToId: "1234.5678", - to: "channel:c123", - }, - { channelId: "slack", conversationId: "" }, - ); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("canonicalizes configured ab-test channel allowlists before matching", async () => { - api.pluginConfig = { abTestChannels: ["channel:c123"] }; - register.register(api as unknown as OpenClawPluginApi); - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await requireHook("message_sending")( - { - content: "hello", - replyToId: "1234.5678", - to: "channel:c123", - }, - { channelId: "slack", conversationId: "" }, - ); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("uses live runtime allowlists when deciding whether to claim ownership", async () => { - api.pluginConfig = { abTestChannels: ["C123"] }; - configFile = { - ...configFile, - plugins: { - entries: { - "thread-ownership": { - config: { - abTestChannels: ["C999"], - }, - }, - }, - }, - }; - register.register(api as unknown as OpenClawPluginApi); - - const result = await requireHook("message_sending")( - { - content: "hello", - replyToId: "1234.5678", - to: "C123", - }, - { channelId: "slack", conversationId: "C123" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("does not fall back to startup allowlists when live plugin config is removed", async () => { - api.pluginConfig = { abTestChannels: ["C999"] }; - register.register(api as unknown as OpenClawPluginApi); - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - const result = await requireHook("message_sending")( - { - content: "hello", - replyToId: "1234.5678", - to: "C123", - }, - { channelId: "slack", conversationId: "C123" }, - ); - - expect(result).toBeUndefined(); - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C123/1234.5678", - "test-agent", - ); - }); - - it("cancels when thread owned by another agent", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }), - ); - - const result = await sendSlackThreadMessage(); - - expect(result).toEqual({ cancel: true }); - const infoMessage = requireFirstLogMessage(api.logger.info, "ownership cancel info log"); - expect(infoMessage).toContain("cancelled send"); - }); - - it("cancels when the forwarder conflict JSON is malformed", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(new Response("{", { status: 409 })); - - const result = await sendSlackThreadMessage(); - - expect(result).toEqual({ cancel: true }); - const warningMessage = requireFirstLogMessage( - api.logger.warn, - "ownership conflict warning log", - ); - expect(warningMessage).toContain("conflict body unreadable"); - expect(warningMessage).toContain("malformed JSON response"); - const infoMessage = requireFirstLogMessage(api.logger.info, "ownership cancel info log"); - expect(infoMessage).toContain("cancelled send"); - expect(infoMessage).toContain("owned by unknown"); - }); - - it("cancels when the forwarder conflict JSON exceeds the bounded read limit", async () => { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "x".repeat(70 * 1024) }), { status: 409 }), - ); - - const result = await sendSlackThreadMessage(); - - expect(result).toEqual({ cancel: true }); - const warningMessage = requireFirstLogMessage( - api.logger.warn, - "ownership conflict warning log", - ); - expect(warningMessage).toContain("conflict body unreadable"); - expect(warningMessage).toContain("JSON response exceeds 65536 bytes"); - const infoMessage = requireFirstLogMessage(api.logger.info, "ownership cancel info log"); - expect(infoMessage).toContain("cancelled send"); - expect(infoMessage).toContain("owned by unknown"); - }); - - it("fails open on network error", async () => { - vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED")); - - const result = await sendSlackThreadMessage(); - - expect(result).toBeUndefined(); - const warningMessage = requireFirstLogMessage(api.logger.warn, "ownership check warning log"); - expect(warningMessage).toContain("ownership check failed"); - }); - }); - - describe("message_received @-mention tracking", () => { - beforeEach(() => { - register.register(api as unknown as OpenClawPluginApi); - }); - - it("tracks @-mentions and skips ownership check for mentioned threads", async () => { - // Simulate receiving a message that @-mentions the agent. - await requireHook("message_received")( - { - content: "Hey @TestBot help me", - threadId: "9999.0001", - metadata: { channelId: "C456" }, - }, - { channelId: "slack", conversationId: "C456" }, - ); - - // Now send in the same thread -- should skip the ownership HTTP call. - const result = await requireHook("message_sending")( - { content: "Sure!", replyToId: "9999.0001", metadata: { channelId: "C456" }, to: "C456" }, - { channelId: "slack", conversationId: "C456" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("tracks mentions under the shared conversationId when inbound metadata is non-canonical", async () => { - await requireHook("message_received")( - { - content: "Hey @TestBot help me", - threadId: "9999.0002", - metadata: { channelId: "channel:c456" }, - }, - { channelId: "slack", conversationId: "C456" }, - ); - - const result = await requireHook("message_sending")( - { - content: "Sure!", - replyToId: "9999.0002", - to: "channel:C456", - }, - { channelId: "slack", conversationId: "C456" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("canonicalizes inbound non-canonical metadata without shared conversation context", async () => { - await requireHook("message_received")( - { - content: "Hey @TestBot help me", - threadId: "9999.0003", - metadata: { channelId: "channel:c456" }, - }, - { channelId: "slack", conversationId: "" }, - ); - - const result = await requireHook("message_sending")( - { - content: "Sure!", - replyToId: "9999.0003", - to: "c456", - }, - { channelId: "slack", conversationId: "C456" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("ignores @-mentions on non-slack channels", async () => { - // Use a unique thread key so module-level state from other tests doesn't interfere. - await requireHook("message_received")( - { content: "Hey @TestBot", threadId: "7777.0001", metadata: { channelId: "C999" } }, - { channelId: "discord", conversationId: "C999" }, - ); - - // The mention should not have been tracked, so sending should still call fetch. - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - await requireHook("message_sending")( - { content: "Sure!", replyToId: "7777.0001", metadata: { channelId: "C999" }, to: "C999" }, - { channelId: "slack", conversationId: "C999" }, - ); - - expect(globalThis.fetch).toHaveBeenCalled(); - }); - - it("tracks bot user ID mentions via <@U999> syntax", async () => { - await requireHook("message_received")( - { - content: "Hey <@U999> help", - threadId: "8888.0001", - metadata: { channelId: "C789" }, - }, - { channelId: "slack", conversationId: "C789" }, - ); - - const result = await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0001", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("tracks agent-name mentions case-insensitively", async () => { - await requireHook("message_received")( - { - content: "hey @testbot help", - threadId: "8888.0002", - metadata: { channelId: "C789" }, - }, - { channelId: "slack", conversationId: "C789" }, - ); - - const result = await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0002", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("uses the live runtime agent identity for ownership claims", async () => { - configFile = { - ...configFile, - agents: { - list: [{ id: "live-agent", default: true, identity: { name: "LiveBot" } }], - }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "live-agent" }), { status: 200 }), - ); - - await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0005", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expectOwnershipFetchCall( - 0, - "http://localhost:8750/api/v1/ownership/C789/8888.0005", - "live-agent", - ); - }); - - it("uses the live runtime agent name for mention tracking", async () => { - configFile = { - ...configFile, - agents: { - list: [{ id: "live-agent", default: true, identity: { name: "LiveBot" } }], - }, - }; - - await requireHook("message_received")( - { - content: "hey @LiveBot help", - threadId: "8888.0006", - metadata: { channelId: "C789" }, - }, - { channelId: "slack", conversationId: "C789" }, - ); - - const result = await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0006", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expect(result).toBeUndefined(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - - it("does not treat superset handles as agent-name mentions", async () => { - await requireHook("message_received")( - { - content: "hey @testbot2 help", - threadId: "8888.0003", - metadata: { channelId: "C789" }, - }, - { channelId: "slack", conversationId: "C789" }, - ); - - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0003", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expect(globalThis.fetch).toHaveBeenCalled(); - }); - - it("does not treat email-like text as an agent-name mention", async () => { - await requireHook("message_received")( - { - content: "send mail to foo@testbot.com", - threadId: "8888.0004", - metadata: { channelId: "C789" }, - }, - { channelId: "slack", conversationId: "C789" }, - ); - - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), - ); - - await requireHook("message_sending")( - { content: "On it!", replyToId: "8888.0004", metadata: { channelId: "C789" }, to: "C789" }, - { channelId: "slack", conversationId: "C789" }, - ); - - expect(globalThis.fetch).toHaveBeenCalled(); - }); - }); -}); diff --git a/extensions/thread-ownership/index.transport.test.ts b/extensions/thread-ownership/index.transport.test.ts deleted file mode 100644 index 3c8370a247e6..000000000000 --- a/extensions/thread-ownership/index.transport.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Real-transport proof: ownership 200 path is status-only and must cancel unread bodies. -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawPluginApi } from "./api.js"; -import register from "./index.js"; - -async function listen(server: ReturnType): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - return `http://127.0.0.1:${address.port}`; -} - -describe("thread-ownership transport body cleanup", () => { - const hooks: Record = {}; - const originalSlackForwarderUrl = process.env.SLACK_FORWARDER_URL; - const originalSlackBotUserId = process.env.SLACK_BOT_USER_ID; - let configFile: Record = {}; - const api = { - pluginConfig: {}, - config: { - agents: { - list: [{ id: "test-agent", default: true, identity: { name: "TestBot" } }], - }, - }, - runtime: { - config: { - current: () => configFile, - }, - }, - id: "thread-ownership", - name: "Thread Ownership", - logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, - on: vi.fn((hookName: string, handler: Function) => { - hooks[hookName] = handler; - }), - }; - - beforeEach(() => { - vi.clearAllMocks(); - for (const key of Object.keys(hooks)) { - delete hooks[key]; - } - api.pluginConfig = {}; - configFile = { agents: api.config.agents }; - process.env.SLACK_BOT_USER_ID = "U999"; - }); - - afterEach(() => { - if (originalSlackForwarderUrl === undefined) { - delete process.env.SLACK_FORWARDER_URL; - } else { - process.env.SLACK_FORWARDER_URL = originalSlackForwarderUrl; - } - if (originalSlackBotUserId === undefined) { - delete process.env.SLACK_BOT_USER_ID; - } else { - process.env.SLACK_BOT_USER_ID = originalSlackBotUserId; - } - }); - - it("cancels unread 200 ownership bodies and closes the request socket", async () => { - let resolveClientClosed: (() => void) | undefined; - const clientClosed = new Promise((resolve) => { - resolveClientClosed = resolve; - }); - const server = createServer((request, response) => { - request.socket.once("close", () => resolveClientClosed?.()); - response.writeHead(200, { "Content-Type": "application/json" }); - response.write('{"ok":true,"agent_id":"test-agent"'); - }); - - const forwarderUrl = await listen(server); - process.env.SLACK_FORWARDER_URL = forwarderUrl; - register.register(api as unknown as OpenClawPluginApi); - const send = expectDefined(hooks.message_sending, "message_sending hook"); - - try { - const result = await send( - { content: "hello", replyToId: "1234.5678", metadata: { channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); - expect(result).toBeUndefined(); - await expect(clientClosed).resolves.toBeUndefined(); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); - - it("cancels unread unexpected-status bodies and closes the request socket", async () => { - let resolveClientClosed: (() => void) | undefined; - const clientClosed = new Promise((resolve) => { - resolveClientClosed = resolve; - }); - const server = createServer((request, response) => { - request.socket.once("close", () => resolveClientClosed?.()); - response.writeHead(500, { "Content-Type": "application/json" }); - response.write('{"error":"boom"'); - }); - - const forwarderUrl = await listen(server); - process.env.SLACK_FORWARDER_URL = forwarderUrl; - register.register(api as unknown as OpenClawPluginApi); - const send = expectDefined(hooks.message_sending, "message_sending hook"); - - try { - const result = await send( - { content: "hello", replyToId: "1234.5678", metadata: { channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); - expect(result).toBeUndefined(); - await expect(clientClosed).resolves.toBeUndefined(); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); -}); diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts deleted file mode 100644 index c7f9b2a09d08..000000000000 --- a/extensions/thread-ownership/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -// Thread Ownership plugin entrypoint registers its OpenClaw integration. -import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime"; -import { - definePluginEntry, - fetchWithSsrFGuard, - readProviderJsonResponse, - ssrfPolicyFromDangerouslyAllowPrivateNetwork, - type OpenClawConfig, - type OpenClawPluginApi, -} from "./api.js"; - -const THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES = 64 * 1024; - -type ThreadOwnershipConfig = { - forwarderUrl?: string; - abTestChannels?: string[]; -}; - -type AgentEntry = NonNullable["list"]>[number]; -type ThreadOwnershipMessageSendingResult = { cancel: true } | undefined; - -// In-memory set of {channel}:{thread} keys where this agent was @-mentioned. -// Entries expire after 5 minutes. -const mentionedThreads = new Map(); -const MENTION_TTL_MS = 5 * 60 * 1000; - -function isThreadOwnershipConfig(value: unknown): value is ThreadOwnershipConfig { - return value !== null && typeof value === "object"; -} - -function resolveThreadToken(value: unknown): string { - return typeof value === "string" || typeof value === "number" ? String(value) : ""; -} - -function resolveSlackConversationId(value: unknown): string { - const raw = normalizeOptionalString(value) ?? ""; - if (!raw) { - return ""; - } - const trimmed = raw.trim(); - const match = /^(?:slack:)?channel:(.+)$/i.exec(trimmed); - const resolved = match?.[1]?.trim() || trimmed; - return /^[CDGUW][A-Z0-9]+$/i.test(resolved) ? resolved.toUpperCase() : resolved; -} - -function cleanExpiredMentions(): void { - const now = Date.now(); - for (const [key, ts] of mentionedThreads) { - if (now - ts > MENTION_TTL_MS) { - mentionedThreads.delete(key); - } - } -} - -function containsAgentNameMention(text: string, agentName: string): boolean { - const trimmedName = agentName.trim(); - if (!trimmedName) { - return false; - } - return new RegExp(`(^|[^\\w])@${escapeRegExp(trimmedName)}(?=$|[^\\w])`, "i").test(text); -} - -function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: string } { - const list = Array.isArray(config.agents?.list) - ? config.agents.list.filter( - (entry): entry is AgentEntry => entry !== null && typeof entry === "object", - ) - : []; - const selected = list.find((entry) => entry.default === true) ?? list[0]; - - const id = normalizeOptionalString(selected?.id) ?? "unknown"; - const identityName = normalizeOptionalString(selected?.identity?.name) ?? ""; - const fallbackName = normalizeOptionalString(selected?.name) ?? ""; - const name = identityName || fallbackName; - - return { id, name }; -} - -export default definePluginEntry({ - id: "thread-ownership", - name: "Thread Ownership", - description: "Slack thread claim coordination for multi-agent setups", - register(api: OpenClawPluginApi) { - const resolveCurrentState = () => { - const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig; - const livePluginCfg = resolveLivePluginConfigObject( - api.runtime.config?.current - ? () => api.runtime.config.current() as OpenClawConfig - : undefined, - "thread-ownership", - isThreadOwnershipConfig(api.pluginConfig) - ? (api.pluginConfig as Record) - : undefined, - ); - const pluginCfg = isThreadOwnershipConfig(livePluginCfg) ? livePluginCfg : {}; - return { - currentConfig, - forwarderUrl: ( - pluginCfg.forwarderUrl ?? - normalizeOptionalString(process.env.SLACK_FORWARDER_URL) ?? - "http://slack-forwarder:8750" - ).replace(/\/$/, ""), - abTestChannels: new Set( - ( - pluginCfg.abTestChannels ?? - process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ?? - [] - ) - .map((entry) => resolveSlackConversationId(entry)) - .filter(Boolean), - ), - botUserId: process.env.SLACK_BOT_USER_ID ?? "", - agent: resolveOwnershipAgent(currentConfig), - }; - }; - - api.on("message_received", async (event, ctx) => { - if (ctx.channelId !== "slack") { - return; - } - const { agent, botUserId } = resolveCurrentState(); - - const text = event.content ?? ""; - const threadTs = - resolveThreadToken(event.threadId) || - resolveThreadToken(event.metadata?.threadId) || - resolveThreadToken(event.metadata?.threadTs); - const channelId = - resolveSlackConversationId(ctx.conversationId) || - resolveSlackConversationId(event.metadata?.channelId) || - ""; - if (!threadTs || !channelId) { - return; - } - - const mentioned = - containsAgentNameMention(text, agent.name) || - (botUserId && text.includes(`<@${botUserId}>`)); - if (mentioned) { - cleanExpiredMentions(); - mentionedThreads.set(`${channelId}:${threadTs}`, Date.now()); - } - }); - - api.on("message_sending", async (event, ctx): Promise => { - if (ctx.channelId !== "slack") { - return undefined; - } - const { abTestChannels, agent, forwarderUrl } = resolveCurrentState(); - - const threadTs = - resolveThreadToken(event.replyToId) || - resolveThreadToken(event.threadId) || - resolveThreadToken(event.metadata?.threadId) || - resolveThreadToken(event.metadata?.threadTs); - const channelId = - resolveSlackConversationId(ctx.conversationId) || - resolveSlackConversationId(event.metadata?.channelId) || - resolveSlackConversationId(event.to) || - ""; - if (!threadTs || !channelId) { - return undefined; - } - if (abTestChannels.size > 0 && !abTestChannels.has(channelId)) { - return undefined; - } - - cleanExpiredMentions(); - if (mentionedThreads.has(`${channelId}:${threadTs}`)) { - return undefined; - } - - try { - // The forwarder is an internal service (e.g. a Docker container); allow private-network - // access but pin DNS so DNS-rebinding attacks cannot pivot to a different internal host. - const { response: resp, release } = await fetchWithSsrFGuard({ - url: `${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, - init: { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agent_id: agent.id }), - }, - timeoutMs: 3000, - policy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true), - auditContext: "thread-ownership", - }); - - try { - if (resp.ok) { - return undefined; - } - if (resp.status === 409) { - let owner = "unknown"; - try { - const body = await readProviderJsonResponse<{ owner?: unknown }>( - resp, - "thread-ownership forwarder conflict", - { maxBytes: THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES }, - ); - if (typeof body.owner === "string" && body.owner) { - owner = body.owner; - } - } catch (error) { - // A 409 is authoritative even when its body is malformed or oversized. - api.logger.warn?.( - `thread-ownership: conflict body unreadable (${String(error)}), cancelling send`, - ); - } - api.logger.info?.( - `thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${owner}`, - ); - return { cancel: true }; - } - api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`); - } finally { - // 200 / unexpected statuses leave the body unread; 409 may already have - // consumed it via readProviderJsonResponse. release() does not cancel streams. - if (!resp.bodyUsed) { - await resp.body?.cancel().catch(() => undefined); - } - await release(); - } - } catch (err) { - api.logger.warn?.( - `thread-ownership: ownership check failed (${String(err)}), allowing send`, - ); - } - return undefined; - }); - }, -}); diff --git a/extensions/thread-ownership/openclaw.plugin.json b/extensions/thread-ownership/openclaw.plugin.json deleted file mode 100644 index ef5310002844..000000000000 --- a/extensions/thread-ownership/openclaw.plugin.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "id": "thread-ownership", - "activation": { - "onStartup": true - }, - "name": "Thread Ownership", - "description": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API.", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "forwarderUrl": { - "type": "string" - }, - "abTestChannels": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "uiHints": { - "forwarderUrl": { - "label": "Forwarder URL", - "help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)" - }, - "abTestChannels": { - "label": "A/B Test Channels", - "help": "Slack channel IDs where thread ownership is enforced" - } - } -} diff --git a/scripts/generate-plugin-inventory-doc.mts b/scripts/generate-plugin-inventory-doc.mts index 19a0bbafc19d..17ebcacbc2ff 100644 --- a/scripts/generate-plugin-inventory-doc.mts +++ b/scripts/generate-plugin-inventory-doc.mts @@ -6,7 +6,10 @@ import process from "node:process"; import type { PluginManifest as RuntimePluginManifest } from "../src/plugins/manifest-types.js"; import type { PackageManifest as RuntimePackageManifest } from "../src/plugins/package-manifest.js"; import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mts"; -import { resolvePluginSurface } from "./lib/plugin-inventory-doc.mts"; +import { + assertPluginInventoryCoverage, + resolvePluginSurface, +} from "./lib/plugin-inventory-doc.mts"; const DOC_PATH = "docs/plugins/plugin-inventory.md"; const REFERENCE_INDEX_PATH = "docs/plugins/reference.md"; @@ -60,7 +63,7 @@ const RELATED_DOC_PRODUCT_IDS = new Set([ ]); type PluginManifest = Partial; -type PluginPackageJson = RuntimePackageManifest & { +type PluginPackageJson = Partial & { openclaw?: RuntimePackageManifest["openclaw"] & { release?: Partial>; }; @@ -83,7 +86,7 @@ function createPluginRecord(entry: PluginSourceEntry, excludedDirs: Set) id, installRoute: resolveInstallRoute(packageJson, status), name: humanizeId(id), - packageName: packageJson.name ?? "-", + packageName: packageJson.name ?? (status === "core" ? "openclaw" : "-"), status, surface: resolvePluginSurface(manifest), }; @@ -517,8 +520,9 @@ title: "Plugin reference" # Plugin reference -This page is generated from \`extensions/*/package.json\` and -\`openclaw.plugin.json\`. Regenerate it with: +This page is generated from top-level \`extensions/*/openclaw.plugin.json\` +manifests. Package metadata enriches entries when \`package.json\` is present. +Regenerate it with: \`\`\`bash pnpm plugins:inventory:gen @@ -536,10 +540,12 @@ function collectPluginSourceEntries(): PluginSourceEntry[] { .toSorted((left, right) => left.localeCompare(right))) { const packagePath = path.join(EXTENSIONS_DIR, dirName, "package.json"); const manifestPath = path.join(EXTENSIONS_DIR, dirName, "openclaw.plugin.json"); - if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) { + if (!fs.existsSync(manifestPath)) { continue; } - const packageJson = readJsonPath(packagePath) as PluginPackageJson; + const packageJson = fs.existsSync(packagePath) + ? (readJsonPath(packagePath) as PluginPackageJson) + : {}; const manifest = readJsonPath(manifestPath) as PluginManifest; const id = typeof manifest.id === "string" && manifest.id ? manifest.id : dirName; entries.push({ dirName, id, manifest, packageJson }); @@ -547,37 +553,28 @@ function collectPluginSourceEntries(): PluginSourceEntry[] { return entries; } -function validatePluginCoverage(records: PluginRecord[], sourceEntries: PluginSourceEntry[]) { - const expectedIds = sourceEntries - .map((entry) => entry.id) - .toSorted((left, right) => left.localeCompare(right)); - const actualIds = records - .map((record) => record.id) - .toSorted((left, right) => left.localeCompare(right)); - const missing = expectedIds.filter((id) => !actualIds.includes(id)); - const extra = actualIds.filter((id) => !expectedIds.includes(id)); - const duplicateIds = actualIds.filter((id, index) => actualIds.indexOf(id) !== index); - if (missing.length > 0 || extra.length > 0 || duplicateIds.length > 0) { - throw new Error( - [ - "plugin inventory coverage mismatch", - missing.length > 0 ? `missing: ${missing.join(", ")}` : null, - extra.length > 0 ? `extra: ${extra.join(", ")}` : null, - duplicateIds.length > 0 ? `duplicates: ${duplicateIds.join(", ")}` : null, - ] - .filter(Boolean) - .join("; "), - ); - } +function enumerateTopLevelPluginManifests() { + return fs + .readdirSync(EXTENSIONS_DIR) + .toSorted((left, right) => left.localeCompare(right)) + .flatMap((dirName) => { + const manifestPath = path.join(EXTENSIONS_DIR, dirName, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + return []; + } + const manifest = readJsonPath(manifestPath) as PluginManifest; + const id = typeof manifest.id === "string" && manifest.id ? manifest.id : dirName; + return [{ dirName, id }]; + }); } function collectPluginRecords() { const rootPackageJson = readJsonPath(path.join(ROOT, "package.json")) as { files?: unknown[] }; const excludedDirs = collectExcludedPackagedExtensionDirs(rootPackageJson); const sourceEntries = collectPluginSourceEntries(); + assertPluginInventoryCoverage(sourceEntries, enumerateTopLevelPluginManifests()); const records = sourceEntries.map((entry) => createPluginRecord(entry, excludedDirs)); - validatePluginCoverage(records, sourceEntries); return records.toSorted((left, right) => left.id.localeCompare(right.id)); } @@ -627,8 +624,9 @@ title: "Plugin inventory" # Plugin inventory -This page is generated from \`extensions/*/package.json\`, \`openclaw.plugin.json\`, -and the root npm package \`files\` exclusions. Regenerate it with: +This page is generated from top-level \`extensions/*/openclaw.plugin.json\` +manifests and the root npm package \`files\` exclusions. Optional \`package.json\` +metadata enriches package and distribution details. Regenerate it with: \`\`\`bash pnpm plugins:inventory:gen diff --git a/scripts/lib/plugin-inventory-doc.mts b/scripts/lib/plugin-inventory-doc.mts index 5ef62ea652d4..92dc3bd23306 100644 --- a/scripts/lib/plugin-inventory-doc.mts +++ b/scripts/lib/plugin-inventory-doc.mts @@ -2,11 +2,54 @@ type PluginSurfaceManifest = { id?: string; channels?: string[]; providers?: string[]; + commandAliases?: Array<{ name?: string; kind?: string }>; contracts?: Record; dashboard?: Partial>>; skills?: unknown[]; }; +type PluginInventoryCoverageEntry = { + dirName: string; + id: string; +}; + +function duplicateValues(values: string[]) { + return values + .filter((value, index) => values.indexOf(value) !== index) + .filter((value, index, duplicates) => duplicates.indexOf(value) === index) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function assertPluginInventoryCoverage( + collectedEntries: PluginInventoryCoverageEntry[], + manifestEntries: PluginInventoryCoverageEntry[], +) { + const problems: string[] = []; + for (const key of ["dirName", "id"] as const) { + const collected = collectedEntries.map((entry) => entry[key]); + const manifests = manifestEntries.map((entry) => entry[key]); + const missing = manifests + .filter((value) => !collected.includes(value)) + .toSorted((left, right) => left.localeCompare(right)); + const extra = collected + .filter((value) => !manifests.includes(value)) + .toSorted((left, right) => left.localeCompare(right)); + const duplicateIds = key === "id" ? duplicateValues(manifests) : []; + if (missing.length > 0) { + problems.push(`missing ${key}s: ${missing.join(", ")}`); + } + if (extra.length > 0) { + problems.push(`extra ${key}s: ${extra.join(", ")}`); + } + if (duplicateIds.length > 0) { + problems.push(`duplicate manifest ids: ${duplicateIds.join(", ")}`); + } + } + if (problems.length > 0) { + throw new Error(`plugin inventory coverage mismatch; ${problems.join("; ")}`); + } +} + function formatIdentifiers(values: string[]) { return values.map((value) => `\`${value}\``).join(", "); } @@ -40,6 +83,17 @@ export function resolvePluginSurface(manifest: PluginSurfaceManifest) { if (Array.isArray(manifest.providers) && manifest.providers.length > 0) { parts.push(`providers: ${formatIdentifiers(manifest.providers)}`); } + const commands = [ + ...new Set( + (manifest.commandAliases ?? []) + .filter((alias) => alias.kind === "runtime-slash") + .map((alias) => alias.name?.trim()) + .filter((name): name is string => Boolean(name)), + ), + ].toSorted((left, right) => left.localeCompare(right)); + if (commands.length > 0) { + parts.push(`commands: ${formatIdentifiers(commands.map((name) => `/${name}`))}`); + } const contracts = Object.keys(manifest.contracts ?? {}).toSorted((left, right) => left.localeCompare(right), ); diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 5dcbf2b11847..2b663c939904 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -177,7 +177,6 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "synology-chat", "talk-voice", "telegram", - "thread-ownership", "tlon", "voice-call", "vllm", diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index d8505add0be1..5d0acf64b52e 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -106,6 +106,30 @@ describe("doctor stale plugin config helpers", () => { }); }); + it("removes retired thread-ownership config while retaining valid plugin ids", () => { + const result = maybeRepairStalePluginConfig({ + plugins: { + allow: ["discord", "thread-ownership"], + deny: ["thread-ownership", "openai"], + entries: { + discord: { enabled: true }, + "thread-ownership": { enabled: true }, + }, + }, + } as OpenClawConfig); + + expect(result.config.plugins).toEqual({ + allow: ["discord"], + deny: ["openai"], + entries: { discord: { enabled: true } }, + }); + expect(result.changes).toEqual([ + "- plugins.allow: removed 1 stale plugin id (thread-ownership)", + "- plugins.deny: removed 1 stale plugin id (thread-ownership)", + "- plugins.entries: removed 1 stale plugin entry (thread-ownership)", + ]); + }); + it("resets stale plugin slots without changing valid slot sentinels", () => { const cfg = { plugins: { diff --git a/src/infra/dotenv-workspace-blocklist.test.ts b/src/infra/dotenv-workspace-blocklist.test.ts index c8719d9fa0c9..19442b87c2bb 100644 --- a/src/infra/dotenv-workspace-blocklist.test.ts +++ b/src/infra/dotenv-workspace-blocklist.test.ts @@ -199,7 +199,6 @@ describe("workspace .env blocklist completeness", () => { "MATRIX_HOMESERVER", "MINIMAX_API_HOST", "BUZZ_RELAY_URL", - "SLACK_FORWARDER_URL", "SMS_ALLOWED_USERS", "SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION", "SMS_PUBLIC_WEBHOOK_URL", diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index fd00f7eb1fdb..1b4eaea7c5fc 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -295,7 +295,6 @@ describe("loadDotEnv", () => { "EXAMPLE_API_HOST=https://evil-api.example.com", "MINIMAX_API_HOST=https://evil.example.com", "BUZZ_RELAY_URL=wss://evil-buzz.example.com/relay", - "SLACK_FORWARDER_URL=http://evil-forwarder.example.com", "SLACK_API_URL=http://evil-slack.example.com/api/", "SMS_ALLOWED_USERS=*", "SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION=true", @@ -354,7 +353,6 @@ describe("loadDotEnv", () => { delete process.env.EXAMPLE_API_HOST; delete process.env.MINIMAX_API_HOST; delete process.env.BUZZ_RELAY_URL; - delete process.env.SLACK_FORWARDER_URL; delete process.env.SLACK_API_URL; delete process.env.SMS_ALLOWED_USERS; delete process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION; @@ -413,7 +411,6 @@ describe("loadDotEnv", () => { expect(process.env.EXAMPLE_API_HOST).toBeUndefined(); expect(process.env.MINIMAX_API_HOST).toBeUndefined(); expect(process.env.BUZZ_RELAY_URL).toBeUndefined(); - expect(process.env.SLACK_FORWARDER_URL).toBeUndefined(); expect(process.env.SLACK_API_URL).toBeUndefined(); expect(process.env.SMS_ALLOWED_USERS).toBeUndefined(); expect(process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION).toBeUndefined(); diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index cfed6dd4f51a..b3c790179aff 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -190,7 +190,6 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "BUZZ_RELAY_URL", - "SLACK_FORWARDER_URL", "SMS_ALLOWED_USERS", "SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION", "SMS_PUBLIC_WEBHOOK_URL", diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index ba9a90aaa76b..0bc083ed8c23 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -58,7 +58,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "reef", "talk-voice", "teams-meetings", - "thread-ownership", "voice-call", "webhooks", "workboard", diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index e6d9429ea6ec..d354041e619b 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -25,7 +25,6 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_FILES = [ "extensions/memory-core/src/dreaming.ts", "extensions/memory-lancedb/index.ts", "extensions/onepassword/index.ts", - "extensions/thread-ownership/index.ts", "extensions/workboard/index.ts", ] as const; const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = { @@ -45,7 +44,6 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = { "extensions/memory-core/index.ts": ["before_agent_reply", "before_prompt_build"], "extensions/memory-lancedb/index.ts": ["agent_end", "before_prompt_build", "session_end"], "extensions/onepassword/index.ts": ["before_tool_call", "tool_result_persist"], - "extensions/thread-ownership/index.ts": ["message_received", "message_sending"], "extensions/workboard/index.ts": ["subagent_ended"], } as const satisfies Record< (typeof BUNDLED_TYPED_HOOK_REGISTRATION_FILES)[number], @@ -71,11 +69,6 @@ const BUNDLED_LIVE_CONFIG_HOOK_GUARDS = { '"onepassword"', "api.runtime.config?.current", ], - "extensions/thread-ownership/index.ts": [ - "resolveLivePluginConfigObject(", - '"thread-ownership"', - "api.runtime.config?.current?.() ?? api.config", - ], } as const satisfies Record; const BUNDLED_LIVE_CONFIG_PROVIDER_GUARDS = { "extensions/amazon-bedrock/register.sync.runtime.ts": [ diff --git a/test/scripts/plugin-inventory-doc.test.ts b/test/scripts/plugin-inventory-doc.test.ts index 2a020acf6726..97e3f26bcc8a 100644 --- a/test/scripts/plugin-inventory-doc.test.ts +++ b/test/scripts/plugin-inventory-doc.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolvePluginSurface } from "../../scripts/lib/plugin-inventory-doc.mts"; +import { + assertPluginInventoryCoverage, + resolvePluginSurface, +} from "../../scripts/lib/plugin-inventory-doc.mts"; describe("resolvePluginSurface", () => { it("keeps manifest identifiers as inline code while leaving labels visible", () => { @@ -27,6 +30,17 @@ describe("resolvePluginSurface", () => { expect(resolvePluginSurface({})).toBe("plugin"); }); + it("renders only runtime slash command aliases", () => { + expect( + resolvePluginSurface({ + commandAliases: [ + { name: "voice", kind: "runtime-slash" }, + { name: "internal", kind: "activation-only" }, + ], + }), + ).toBe("commands: `/voice`"); + }); + it("escapes dashboard plugin owner delimiters and literal escape markers", () => { expect( resolvePluginSurface({ @@ -42,3 +56,27 @@ describe("resolvePluginSurface", () => { ).toBe("dashboard data bindings: `dashboard%252Esegmented.refresh`"); }); }); + +describe("assertPluginInventoryCoverage", () => { + it("detects a manifest directory omitted from the collected source entries", () => { + expect(() => + assertPluginInventoryCoverage( + [{ dirName: "packaged", id: "packaged" }], + [ + { dirName: "manifest-only", id: "manifest-only" }, + { dirName: "packaged", id: "packaged" }, + ], + ), + ).toThrow(/missing dirNames: manifest-only.*missing ids: manifest-only/u); + }); + + it("detects duplicate ids in the independent manifest enumeration", () => { + const entries = [ + { dirName: "one", id: "duplicate" }, + { dirName: "two", id: "duplicate" }, + ]; + expect(() => assertPluginInventoryCoverage(entries, entries)).toThrow( + "duplicate manifest ids: duplicate", + ); + }); +}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index c11267463129..181233e8be00 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2962,12 +2962,6 @@ describe("scripts/test-projects changed-target routing", () => { }); it.each([ - { - title: "routes misc extensions to the misc extension shard", - target: "extensions/thread-ownership", - config: "test/vitest/vitest.extension-misc.config.ts", - includePattern: "extensions/thread-ownership/**/*.test.ts", - }, { title: "routes explicit plugin-sdk light tests to the lighter plugin-sdk lane", target: "src/plugin-sdk/temp-path.test.ts", diff --git a/test/vitest/vitest.extension-misc-paths.mjs b/test/vitest/vitest.extension-misc-paths.mjs index 83fd6d553600..acf963bd495b 100644 --- a/test/vitest/vitest.extension-misc-paths.mjs +++ b/test/vitest/vitest.extension-misc-paths.mjs @@ -21,7 +21,6 @@ export const miscExtensionTestRoots = [ "extensions/searxng", "extensions/synthetic", "extensions/tavily", - "extensions/thread-ownership", "extensions/vercel-ai-gateway", "extensions/webhooks", ]; diff --git a/ui/src/pages/plugins/presentation.ts b/ui/src/pages/plugins/presentation.ts index 5ea8b7e9a36c..485daad4a7c9 100644 --- a/ui/src/pages/plugins/presentation.ts +++ b/ui/src/pages/plugins/presentation.ts @@ -153,7 +153,6 @@ const PLUGIN_ART_SLUGS: ReadonlySet = new Set([ "tavily", "telegram", "tencent", - "thread-ownership", "tlon", "todoist", "together",