mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
ca6a379745
commit
f44c5e2e5e
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -16,4 +16,4 @@ Adds agent-callable tools.
|
||||
|
||||
## Surface
|
||||
|
||||
contracts: `tools`
|
||||
commands: `/dreaming`; contracts: `tools`
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -237,7 +237,6 @@ export default definePluginEntry({
|
||||
voiceId: chosen.id,
|
||||
},
|
||||
},
|
||||
...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}),
|
||||
},
|
||||
};
|
||||
Object.assign(draft, nextConfig);
|
||||
|
||||
@@ -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";
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -153,7 +153,6 @@ const PLUGIN_ART_SLUGS: ReadonlySet<string> = new Set([
|
||||
"tavily",
|
||||
"telegram",
|
||||
"tencent",
|
||||
"thread-ownership",
|
||||
"tlon",
|
||||
"todoist",
|
||||
"together",
|
||||
|
||||
Reference in New Issue
Block a user