fix(plugins): surface manifest-only bundled capabilities (#121354)

Use a manifest-first inventory with independent coverage for manifest-only bundled capabilities.

Retire the undocumented thread-ownership plugin while Doctor removes stale references.

Document Talk voice and persist only provider-scoped voice selection.

Closes #121353
This commit is contained in:
Peter Steinberger
2026-08-09 19:43:49 -07:00
committed by GitHub
parent ca6a379745
commit f44c5e2e5e
31 changed files with 255 additions and 1068 deletions
-4
View File
@@ -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:
+6
View File
@@ -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.<provider>` configuration, use `/voice status` to inspect the active provider and voice, `/voice list [limit]` to list its available voices, and `/voice set <voiceId|name>` 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
+10 -3
View File
@@ -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.
+4 -3
View File
@@ -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.
+19
View File
@@ -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
+1 -1
View File
@@ -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
+19
View File
@@ -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`
+1 -1
View File
@@ -16,4 +16,4 @@ Adds agent-callable tools.
## Surface
contracts: `tools`
commands: `/dreaming`; contracts: `tools`
+35
View File
@@ -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`
<!-- openclaw-plugin-reference:manual-start -->
## Configure a Talk voice from chat
Set `talk.provider` and configure the matching `talk.providers.<provider>` 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 <voiceId|name>` resolves a voice by exact ID, exact name, or partial name, then saves it to `talk.providers.<activeProvider>.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.
<!-- openclaw-plugin-reference:manual-end -->
+7 -7
View File
@@ -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 <voiceId>` | Manage Talk voice config. Discord native name: `/talkvoice` |
| `/card ...` | Send LINE rich card presets. See [LINE](/channels/line) |
| `/codex <action> ...` | 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 <voiceId>` | Manage Talk voice config. Discord native name: `/talkvoice` |
| `/card ...` | Send LINE rich card presets. See [LINE](/channels/line) |
| `/codex <action> ...` | 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`
@@ -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"],
});
+4 -3
View File
@@ -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<string, unknown> };
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",
});
-1
View File
@@ -237,7 +237,6 @@ export default definePluginEntry({
voiceId: chosen.id,
},
},
...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}),
},
};
Object.assign(draft, nextConfig);
-8
View File
@@ -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";
-588
View File
@@ -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<string, Function> = {};
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<string, unknown> = {};
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<typeof vi.fn>, 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();
});
});
});
@@ -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<typeof createServer>): Promise<string> {
await new Promise<void>((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<string, Function> = {};
const originalSlackForwarderUrl = process.env.SLACK_FORWARDER_URL;
const originalSlackBotUserId = process.env.SLACK_BOT_USER_ID;
let configFile: Record<string, unknown> = {};
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<void>((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<void>((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<void>((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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
-233
View File
@@ -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<NonNullable<OpenClawConfig["agents"]>["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<string, number>();
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<string, unknown>)
: 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<ThreadOwnershipMessageSendingResult> => {
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;
});
},
});
@@ -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"
}
}
}
+30 -32
View File
@@ -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<RuntimePluginManifest>;
type PluginPackageJson = RuntimePackageManifest & {
type PluginPackageJson = Partial<RuntimePackageManifest> & {
openclaw?: RuntimePackageManifest["openclaw"] & {
release?: Partial<Record<"publishToClawHub" | "publishToNpm", boolean>>;
};
@@ -83,7 +86,7 @@ function createPluginRecord(entry: PluginSourceEntry, excludedDirs: Set<string>)
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
+54
View File
@@ -2,11 +2,54 @@ type PluginSurfaceManifest = {
id?: string;
channels?: string[];
providers?: string[];
commandAliases?: Array<{ name?: string; kind?: string }>;
contracts?: Record<string, unknown>;
dashboard?: Partial<Record<"actionVerbs" | "dataBindings", Array<{ id?: string }>>>;
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),
);
@@ -177,7 +177,6 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [
"synology-chat",
"talk-voice",
"telegram",
"thread-ownership",
"tlon",
"voice-call",
"vllm",
@@ -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: {
@@ -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",
-3
View File
@@ -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();
-1
View File
@@ -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",
@@ -58,7 +58,6 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"reef",
"talk-voice",
"teams-meetings",
"thread-ownership",
"voice-call",
"webhooks",
"workboard",
@@ -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<string, readonly string[]>;
const BUNDLED_LIVE_CONFIG_PROVIDER_GUARDS = {
"extensions/amazon-bedrock/register.sync.runtime.ts": [
+39 -1
View File
@@ -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",
);
});
});
-6
View File
@@ -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",
@@ -21,7 +21,6 @@ export const miscExtensionTestRoots = [
"extensions/searxng",
"extensions/synthetic",
"extensions/tavily",
"extensions/thread-ownership",
"extensions/vercel-ai-gateway",
"extensions/webhooks",
];
-1
View File
@@ -153,7 +153,6 @@ const PLUGIN_ART_SLUGS: ReadonlySet<string> = new Set([
"tavily",
"telegram",
"tencent",
"thread-ownership",
"tlon",
"todoist",
"together",