Merge branch 'main' into fix/token-usage-input-output-breakdown
This commit is contained in:
commit
3af18e3591
10
CHANGELOG.md
10
CHANGELOG.md
@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
@ -26,16 +27,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. Thanks @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
@ -43,8 +44,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. Thanks @vincentkoc.
|
||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. Thanks @vincentkoc.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
|
||||
@ -532,6 +532,75 @@ Feishu supports streaming replies via interactive cards. When enabled, the bot u
|
||||
|
||||
Set `streaming: false` to wait for the full reply before sending.
|
||||
|
||||
### ACP sessions
|
||||
|
||||
Feishu supports ACP for:
|
||||
|
||||
- DMs
|
||||
- group topic conversations
|
||||
|
||||
Feishu ACP is text-command driven. There are no native slash-command menus, so use `/acp ...` messages directly in the conversation.
|
||||
|
||||
#### Persistent ACP bindings
|
||||
|
||||
Use top-level typed ACP bindings to pin a Feishu DM or topic conversation to a persistent ACP session.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "codex",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
cwd: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "ou_1234567890" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root" },
|
||||
},
|
||||
acp: { label: "codex-feishu-topic" },
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
#### Thread-bound ACP spawn from chat
|
||||
|
||||
In a Feishu DM or topic conversation, you can spawn and bind an ACP session in place:
|
||||
|
||||
```text
|
||||
/acp spawn codex --thread here
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--thread here` works for DMs and Feishu topics.
|
||||
- Follow-up messages in the bound DM/topic route directly to that ACP session.
|
||||
- v1 does not target generic non-topic group chats.
|
||||
|
||||
### Multi-agent routing
|
||||
|
||||
Use `bindings` to route Feishu DMs or groups to different agents.
|
||||
|
||||
@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, safe regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
|
||||
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
|
||||
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||
- Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
|
||||
@ -50,7 +50,7 @@ Add a `groupChat` block to `~/.openclaw/openclaw.json` so display-name pings wor
|
||||
|
||||
Notes:
|
||||
|
||||
- The regexes are case-insensitive; they cover a display-name ping like `@openclaw` and the raw number with or without `+`/spaces.
|
||||
- The regexes are case-insensitive and use the same safe-regex guardrails as other config regex surfaces; invalid patterns and unsafe nested repetition are ignored.
|
||||
- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a useful safety net.
|
||||
|
||||
### Activation command (owner-only)
|
||||
|
||||
@ -243,7 +243,7 @@ Replying to a bot message counts as an implicit mention (when the channel suppor
|
||||
|
||||
Notes:
|
||||
|
||||
- `mentionPatterns` are case-insensitive regexes.
|
||||
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
|
||||
@ -655,12 +655,12 @@ See the full channel index: [Channels](/channels).
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
|
||||
**Mention types:**
|
||||
|
||||
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
|
||||
- **Text patterns**: Regex patterns in `agents.list[].groupChat.mentionPatterns`. Always checked.
|
||||
- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored.
|
||||
- Mention gating is enforced only when detection is possible (native mentions or at least one pattern).
|
||||
|
||||
```json5
|
||||
@ -2370,6 +2370,7 @@ See [Plugins](/tools/plugin).
|
||||
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
|
||||
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model).
|
||||
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation.
|
||||
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.
|
||||
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
|
||||
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||
|
||||
@ -170,7 +170,7 @@ When validation fails:
|
||||
```
|
||||
|
||||
- **Metadata mentions**: native @-mentions (WhatsApp tap-to-mention, Telegram @bot, etc.)
|
||||
- **Text patterns**: regex patterns in `mentionPatterns`
|
||||
- **Text patterns**: safe regex patterns in `mentionPatterns`
|
||||
- See [full reference](/gateway/configuration-reference#group-chat-mention-gating) for per-channel overrides and self-chat mode.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@ -114,6 +114,7 @@ Notes:
|
||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
||||
- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation.
|
||||
- In strict SSRF mode, remote CDP endpoint discovery/probes (`cdpUrl`, including `/json/version` lookups) are checked too.
|
||||
- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing.
|
||||
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
|
||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||
|
||||
68
extensions/feishu/index.test.ts
Normal file
68
extensions/feishu/index.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("./index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
@ -7,6 +7,7 @@ import { registerFeishuDocTools } from "./src/docx.js";
|
||||
import { registerFeishuDriveTools } from "./src/drive.js";
|
||||
import { registerFeishuPermTools } from "./src/perm.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
import { registerFeishuSubagentHooks } from "./src/subagent-hooks.js";
|
||||
import { registerFeishuWikiTools } from "./src/wiki.js";
|
||||
|
||||
export { monitorFeishuProvider } from "./src/monitor.js";
|
||||
@ -53,6 +54,7 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
registerFeishuSubagentHooks(api);
|
||||
registerFeishuDocTools(api);
|
||||
registerFeishuChatTools(api);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
@ -21,6 +21,10 @@ const {
|
||||
mockResolveAgentRoute,
|
||||
mockReadSessionUpdatedAt,
|
||||
mockResolveStorePath,
|
||||
mockResolveConfiguredAcpRoute,
|
||||
mockEnsureConfiguredAcpRouteReady,
|
||||
mockResolveBoundConversation,
|
||||
mockTouchBinding,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||
dispatcher: vi.fn(),
|
||||
@ -46,6 +50,13 @@ const {
|
||||
})),
|
||||
mockReadSessionUpdatedAt: vi.fn(),
|
||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||
mockResolveConfiguredAcpRoute: vi.fn(({ route }) => ({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
})),
|
||||
mockEnsureConfiguredAcpRouteReady: vi.fn(async (_params?: unknown) => ({ ok: true })),
|
||||
mockResolveBoundConversation: vi.fn(() => null),
|
||||
mockTouchBinding: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-dispatcher.js", () => ({
|
||||
@ -66,6 +77,18 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/acp/persistent-bindings.route.js", () => ({
|
||||
resolveConfiguredAcpRoute: (params: unknown) => mockResolveConfiguredAcpRoute(params),
|
||||
ensureConfiguredAcpRouteReady: (params: unknown) => mockEnsureConfiguredAcpRouteReady(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
getSessionBindingService: () => ({
|
||||
resolveByConversation: mockResolveBoundConversation,
|
||||
touch: mockTouchBinding,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
@ -110,6 +133,261 @@ describe("buildFeishuAgentBody", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage ACP routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReset().mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:feishu:direct:ou_sender_1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
});
|
||||
mockSendMessageFeishu
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
|
||||
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
} as any,
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
});
|
||||
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
session: {
|
||||
readSessionUpdatedAt:
|
||||
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
resolveStorePath:
|
||||
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
||||
finalizeInboundContext: ((ctx: unknown) =>
|
||||
ctx) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
|
||||
queuedFinal: false,
|
||||
counts: { final: 1 },
|
||||
}),
|
||||
withReplyDispatcher: vi.fn(
|
||||
async ({
|
||||
run,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) =>
|
||||
await run(),
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
|
||||
upsertPairingRequest: vi.fn(),
|
||||
buildPairingReply: vi.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ensures configured ACP routes for Feishu DMs", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-1",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveConfiguredAcpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureConfiguredAcpRouteReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
||||
mockResolveConfiguredAcpRoute.mockReturnValue({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:feishu:default:ou_sender_1",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
metadata: { source: "config" },
|
||||
},
|
||||
},
|
||||
route: {
|
||||
agentId: "codex",
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
} as any);
|
||||
mockEnsureConfiguredAcpRouteReady.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "runtime unavailable",
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-2",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc_dm",
|
||||
text: expect.stringContaining("runtime unavailable"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Feishu topic messages through active bound conversations", async () => {
|
||||
mockResolveBoundConversation.mockReturnValue({
|
||||
bindingId: "default:oc_group_chat:topic:om_topic_root",
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
} as any);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
allowFrom: ["ou_sender_1"],
|
||||
groups: {
|
||||
oc_group_chat: {
|
||||
allow: true,
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-3",
|
||||
chat_id: "oc_group_chat",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
root_id: "om_topic_root",
|
||||
content: JSON.stringify({ text: "hello topic" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockResolveBoundConversation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "feishu",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
);
|
||||
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleFeishuMessage command authorization", () => {
|
||||
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
||||
const mockDispatchReplyFromConfig = vi
|
||||
@ -153,6 +431,16 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||
mockResolveConfiguredAcpRoute.mockReset().mockImplementation(
|
||||
({ route }) =>
|
||||
({
|
||||
configuredBinding: null,
|
||||
route,
|
||||
}) as any,
|
||||
);
|
||||
mockEnsureConfiguredAcpRouteReady.mockReset().mockResolvedValue({ ok: true });
|
||||
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
||||
mockTouchBinding.mockReset();
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
|
||||
@ -14,8 +14,16 @@ import {
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
} from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
||||
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
@ -273,15 +281,34 @@ function resolveFeishuGroupSession(params: {
|
||||
let peerId = chatId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = `${chatId}:sender:${senderOpenId}`;
|
||||
peerId = buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
||||
peerId = topicScope
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId: topicScope,
|
||||
})
|
||||
: chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
||||
: `${chatId}:sender:${senderOpenId}`;
|
||||
? buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: topicScope,
|
||||
senderOpenId,
|
||||
})
|
||||
: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
});
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
@ -1168,6 +1195,10 @@ export async function handleFeishuMessage(params: {
|
||||
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
||||
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
||||
const feishuAcpConversationSupported =
|
||||
!isGroup ||
|
||||
groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender";
|
||||
|
||||
if (isGroup && groupSession) {
|
||||
log(
|
||||
@ -1216,6 +1247,76 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const currentConversationId = peerId;
|
||||
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
||||
let configuredBinding = null;
|
||||
if (feishuAcpConversationSupported) {
|
||||
const configuredRoute = resolveConfiguredAcpRoute({
|
||||
cfg: effectiveCfg,
|
||||
route,
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
configuredBinding = configuredRoute.configuredBinding;
|
||||
route = configuredRoute.route;
|
||||
|
||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
|
||||
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
log(
|
||||
`feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
cfg: effectiveCfg,
|
||||
configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
const replyTargetMessageId =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender")
|
||||
? (ctx.rootId ?? ctx.messageId)
|
||||
: ctx.messageId;
|
||||
await sendMessageFeishu({
|
||||
cfg: effectiveCfg,
|
||||
to: `chat:${ctx.chatId}`,
|
||||
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isGroup
|
||||
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
||||
|
||||
125
extensions/feishu/src/conversation-id.ts
Normal file
125
extensions/feishu/src/conversation-id.ts
Normal file
@ -0,0 +1,125 @@
|
||||
export type FeishuGroupSessionScope =
|
||||
| "group"
|
||||
| "group_sender"
|
||||
| "group_topic"
|
||||
| "group_topic_sender";
|
||||
|
||||
function normalizeText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function buildFeishuConversationId(params: {
|
||||
chatId: string;
|
||||
scope: FeishuGroupSessionScope;
|
||||
senderOpenId?: string;
|
||||
topicId?: string;
|
||||
}): string {
|
||||
const chatId = normalizeText(params.chatId) ?? "unknown";
|
||||
const senderOpenId = normalizeText(params.senderOpenId);
|
||||
const topicId = normalizeText(params.topicId);
|
||||
|
||||
switch (params.scope) {
|
||||
case "group_sender":
|
||||
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
||||
case "group_topic":
|
||||
return topicId ? `${chatId}:topic:${topicId}` : chatId;
|
||||
case "group_topic_sender":
|
||||
if (topicId && senderOpenId) {
|
||||
return `${chatId}:topic:${topicId}:sender:${senderOpenId}`;
|
||||
}
|
||||
if (topicId) {
|
||||
return `${chatId}:topic:${topicId}`;
|
||||
}
|
||||
return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId;
|
||||
case "group":
|
||||
default:
|
||||
return chatId;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFeishuConversationId(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): {
|
||||
canonicalConversationId: string;
|
||||
chatId: string;
|
||||
topicId?: string;
|
||||
senderOpenId?: string;
|
||||
scope: FeishuGroupSessionScope;
|
||||
} | null {
|
||||
const conversationId = normalizeText(params.conversationId);
|
||||
const parentConversationId = normalizeText(params.parentConversationId);
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/);
|
||||
if (topicSenderMatch) {
|
||||
const [, chatId, topicId, senderOpenId] = topicSenderMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic_sender",
|
||||
topicId,
|
||||
senderOpenId,
|
||||
}),
|
||||
chatId,
|
||||
topicId,
|
||||
senderOpenId,
|
||||
scope: "group_topic_sender",
|
||||
};
|
||||
}
|
||||
|
||||
const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/);
|
||||
if (topicMatch) {
|
||||
const [, chatId, topicId] = topicMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_topic",
|
||||
topicId,
|
||||
}),
|
||||
chatId,
|
||||
topicId,
|
||||
scope: "group_topic",
|
||||
};
|
||||
}
|
||||
|
||||
const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/);
|
||||
if (senderMatch) {
|
||||
const [, chatId, senderOpenId] = senderMatch;
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId,
|
||||
scope: "group_sender",
|
||||
senderOpenId,
|
||||
}),
|
||||
chatId,
|
||||
senderOpenId,
|
||||
scope: "group_sender",
|
||||
};
|
||||
}
|
||||
|
||||
if (parentConversationId) {
|
||||
return {
|
||||
canonicalConversationId: buildFeishuConversationId({
|
||||
chatId: parentConversationId,
|
||||
scope: "group_topic",
|
||||
topicId: conversationId,
|
||||
}),
|
||||
chatId: parentConversationId,
|
||||
topicId: conversationId,
|
||||
scope: "group_topic",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalConversationId: conversationId,
|
||||
chatId: conversationId,
|
||||
scope: "group",
|
||||
};
|
||||
}
|
||||
@ -24,6 +24,7 @@ import { botNames, botOpenIds } from "./monitor.state.js";
|
||||
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import { createFeishuThreadBindingManager } from "./thread-bindings.js";
|
||||
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
||||
@ -631,19 +632,25 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
||||
}
|
||||
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
|
||||
try {
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
threadBindingManager = createFeishuThreadBindingManager({ accountId, cfg });
|
||||
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
registerEventHandlers(eventDispatcher, {
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
fireAndForget: true,
|
||||
});
|
||||
|
||||
if (connectionMode === "webhook") {
|
||||
return monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
if (connectionMode === "webhook") {
|
||||
return await monitorWebhook({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
return await monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
} finally {
|
||||
threadBindingManager?.stop();
|
||||
}
|
||||
return monitorWebSocket({ account, accountId, runtime, abortSignal, eventDispatcher });
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
|
||||
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
|
||||
|
||||
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
@ -37,6 +38,10 @@ vi.mock("./monitor.transport.js", () => ({
|
||||
monitorWebhook: monitorWebhookMock,
|
||||
}));
|
||||
|
||||
vi.mock("./thread-bindings.js", () => ({
|
||||
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
|
||||
}));
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
function makeReactionEvent(
|
||||
@ -419,6 +424,94 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitorSingleAccount lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
|
||||
stop: vi.fn(),
|
||||
}));
|
||||
createEventDispatcherMock.mockReset().mockReturnValue({
|
||||
register: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the Feishu thread binding manager when the monitor exits", async () => {
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs,
|
||||
createInboundDebouncer,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
},
|
||||
});
|
||||
|
||||
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
||||
| { stop: ReturnType<typeof vi.fn> }
|
||||
| undefined;
|
||||
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
|
||||
setFeishuRuntime(
|
||||
createPluginRuntimeMock({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs,
|
||||
createInboundDebouncer,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
createEventDispatcherMock.mockReturnValue({
|
||||
get register() {
|
||||
throw new Error("register failed");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("register failed");
|
||||
|
||||
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
||||
| { stop: ReturnType<typeof vi.fn> }
|
||||
| undefined;
|
||||
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feishu inbound debounce regressions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
623
extensions/feishu/src/subagent-hooks.test.ts
Normal file
623
extensions/feishu/src/subagent-hooks.test.ts
Normal file
@ -0,0 +1,623 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerFeishuSubagentHooks } from "./subagent-hooks.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const baseConfig = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: {} },
|
||||
};
|
||||
|
||||
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||
const api = {
|
||||
config,
|
||||
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
||||
handlers.set(hookName, handler);
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerFeishuSubagentHooks(api);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
function getRequiredHandler(
|
||||
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
|
||||
hookName: string,
|
||||
): (event: unknown, ctx: unknown) => unknown {
|
||||
const handler = handlers.get(hookName);
|
||||
if (!handler) {
|
||||
throw new Error(`expected ${hookName} hook handler`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("feishu subagent hook handlers", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("registers Feishu subagent hooks", () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
expect(handlers.has("subagent_spawning")).toBe(true);
|
||||
expect(handlers.has("subagent_delivery_target")).toBe(true);
|
||||
expect(handlers.has("subagent_ended")).toBe(true);
|
||||
expect(handlers.has("subagent_spawned")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds a Feishu DM conversation on subagent_spawning", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
label: "banana",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
|
||||
const deliveryTargetHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
expect(
|
||||
deliveryTargetHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the original Feishu DM delivery target", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "ou_sender_1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
metadata: {
|
||||
deliveryTo: "chat:oc_dm_chat_1",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:chat-dm-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_dm_chat_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a Feishu topic conversation and preserves parent context", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
const result = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
agentId: "codex",
|
||||
label: "topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:topic-child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the requester session binding to preserve sender-scoped topic conversations", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "parent",
|
||||
boundBy: "system",
|
||||
},
|
||||
});
|
||||
|
||||
const reboundResult = await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
agentId: "codex",
|
||||
label: "sender-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
);
|
||||
|
||||
expect(reboundResult).toEqual({ status: "ok", threadBindingReady: true });
|
||||
expect(manager.listBySessionKey("agent:main:subagent:sender-child")).toMatchObject([
|
||||
{
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:sender-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers requester-matching bindings when multiple child bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
agentId: "codex",
|
||||
label: "shared",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:shared",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
origin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when requester-session bindings remain ambiguous for the same topic", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_2",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
agentId: "codex",
|
||||
label: "ambiguous-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:ambiguous-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when both topic-level and sender-scoped requester bindings exist", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
manager.bindConversation({
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:parent",
|
||||
metadata: { boundBy: "system" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
agentId: "codex",
|
||||
label: "mixed-topic-child",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:mixed-topic-child",
|
||||
requesterSessionKey: "agent:main:parent",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
threadId: "om_topic_root",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops for non-Feishu channels and non-threaded spawns", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: false,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an error for unsupported non-topic Feishu group conversations", async () => {
|
||||
const handler = getRequiredHandler(registerHandlersForTest(), "subagent_spawning");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await expect(
|
||||
handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "chat:oc_group_chat",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("direct messages or topic conversations"),
|
||||
});
|
||||
});
|
||||
|
||||
it("unbinds Feishu bindings on subagent_ended", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
const endedHandler = getRequiredHandler(handlers, "subagent_ended");
|
||||
createFeishuThreadBindingManager({ cfg: baseConfig as any, accountId: "work" });
|
||||
|
||||
await spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
endedHandler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "done",
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when the Feishu monitor-owned binding manager is unavailable", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const spawnHandler = getRequiredHandler(handlers, "subagent_spawning");
|
||||
const deliveryHandler = getRequiredHandler(handlers, "subagent_delivery_target");
|
||||
|
||||
await expect(
|
||||
spawnHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringContaining("monitor is not active"),
|
||||
});
|
||||
|
||||
expect(
|
||||
deliveryHandler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:no-manager",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
to: "user:ou_sender_1",
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
341
extensions/feishu/src/subagent-hooks.ts
Normal file
341
extensions/feishu/src/subagent-hooks.ts
Normal file
@ -0,0 +1,341 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
|
||||
import { normalizeFeishuTarget } from "./targets.js";
|
||||
import { getFeishuThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
function summarizeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function stripProviderPrefix(raw: string): string {
|
||||
return raw.replace(/^(feishu|lark):/i, "").trim();
|
||||
}
|
||||
|
||||
function resolveFeishuRequesterConversation(params: {
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
requesterSessionKey?: string;
|
||||
}): {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const manager = getFeishuThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const rawTo = params.to?.trim();
|
||||
const withoutProviderPrefix = rawTo ? stripProviderPrefix(rawTo) : "";
|
||||
const normalizedTarget = rawTo ? normalizeFeishuTarget(rawTo) : null;
|
||||
const threadId =
|
||||
params.threadId != null && params.threadId !== "" ? String(params.threadId).trim() : "";
|
||||
const isChatTarget = /^(chat|group|channel):/i.test(withoutProviderPrefix);
|
||||
const parsedRequesterTopic =
|
||||
normalizedTarget && threadId && isChatTarget
|
||||
? parseFeishuConversationId({
|
||||
conversationId: buildFeishuConversationId({
|
||||
chatId: normalizedTarget,
|
||||
scope: "group_topic",
|
||||
topicId: threadId,
|
||||
}),
|
||||
parentConversationId: normalizedTarget,
|
||||
})
|
||||
: null;
|
||||
const requesterSessionKey = params.requesterSessionKey?.trim();
|
||||
if (requesterSessionKey) {
|
||||
const existingBindings = manager.listBySessionKey(requesterSessionKey);
|
||||
if (existingBindings.length === 1) {
|
||||
const existing = existingBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
if (existingBindings.length > 1) {
|
||||
if (rawTo && normalizedTarget && !threadId && !isChatTarget) {
|
||||
const directMatches = existingBindings.filter(
|
||||
(entry) =>
|
||||
entry.accountId === manager.accountId &&
|
||||
entry.conversationId === normalizedTarget &&
|
||||
!entry.parentConversationId,
|
||||
);
|
||||
if (directMatches.length === 1) {
|
||||
const existing = directMatches[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (parsedRequesterTopic) {
|
||||
const matchingTopicBindings = existingBindings.filter((entry) => {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: entry.conversationId,
|
||||
parentConversationId: entry.parentConversationId,
|
||||
});
|
||||
return (
|
||||
parsed?.chatId === parsedRequesterTopic.chatId &&
|
||||
parsed?.topicId === parsedRequesterTopic.topicId
|
||||
);
|
||||
});
|
||||
if (matchingTopicBindings.length === 1) {
|
||||
const existing = matchingTopicBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
const senderScopedTopicBindings = matchingTopicBindings.filter((entry) => {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: entry.conversationId,
|
||||
parentConversationId: entry.parentConversationId,
|
||||
});
|
||||
return parsed?.scope === "group_topic_sender";
|
||||
});
|
||||
if (
|
||||
senderScopedTopicBindings.length === 1 &&
|
||||
matchingTopicBindings.length === senderScopedTopicBindings.length
|
||||
) {
|
||||
const existing = senderScopedTopicBindings[0];
|
||||
return {
|
||||
accountId: existing.accountId,
|
||||
conversationId: existing.conversationId,
|
||||
parentConversationId: existing.parentConversationId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawTo) {
|
||||
return null;
|
||||
}
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (threadId) {
|
||||
if (!isChatTarget) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId: manager.accountId,
|
||||
conversationId: buildFeishuConversationId({
|
||||
chatId: normalizedTarget,
|
||||
scope: "group_topic",
|
||||
topicId: threadId,
|
||||
}),
|
||||
parentConversationId: normalizedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
if (isChatTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: manager.accountId,
|
||||
conversationId: normalizedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeishuDeliveryOrigin(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
accountId: string;
|
||||
deliveryTo?: string;
|
||||
deliveryThreadId?: string;
|
||||
}): {
|
||||
channel: "feishu";
|
||||
accountId: string;
|
||||
to: string;
|
||||
threadId?: string;
|
||||
} {
|
||||
const deliveryTo = params.deliveryTo?.trim();
|
||||
const deliveryThreadId = params.deliveryThreadId?.trim();
|
||||
if (deliveryTo) {
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: deliveryTo,
|
||||
...(deliveryThreadId ? { threadId: deliveryThreadId } : {}),
|
||||
};
|
||||
}
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (parsed?.topicId) {
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: `chat:${params.parentConversationId?.trim() || parsed.chatId}`,
|
||||
threadId: parsed.topicId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId,
|
||||
to: `user:${params.conversationId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatchingChildBinding(params: {
|
||||
accountId?: string;
|
||||
childSessionKey: string;
|
||||
requesterSessionKey?: string;
|
||||
requesterOrigin?: {
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
}) {
|
||||
const manager = getFeishuThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const childBindings = manager.listBySessionKey(params.childSessionKey.trim());
|
||||
if (childBindings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requesterConversation = resolveFeishuRequesterConversation({
|
||||
accountId: manager.accountId,
|
||||
to: params.requesterOrigin?.to,
|
||||
threadId: params.requesterOrigin?.threadId,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
});
|
||||
if (requesterConversation) {
|
||||
const matched = childBindings.find(
|
||||
(entry) =>
|
||||
entry.accountId === requesterConversation.accountId &&
|
||||
entry.conversationId === requesterConversation.conversationId &&
|
||||
(entry.parentConversationId?.trim() || undefined) ===
|
||||
(requesterConversation.parentConversationId?.trim() || undefined),
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return childBindings.length === 1 ? childBindings[0] : null;
|
||||
}
|
||||
|
||||
export function registerFeishuSubagentHooks(api: OpenClawPluginApi) {
|
||||
api.on("subagent_spawning", async (event, ctx) => {
|
||||
if (!event.threadRequested) {
|
||||
return;
|
||||
}
|
||||
const requesterChannel = event.requester?.channel?.trim().toLowerCase();
|
||||
if (requesterChannel !== "feishu") {
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = getFeishuThreadBindingManager(event.requester?.accountId);
|
||||
if (!manager) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Feishu current-conversation binding is unavailable because the Feishu account monitor is not active.",
|
||||
};
|
||||
}
|
||||
|
||||
const conversation = resolveFeishuRequesterConversation({
|
||||
accountId: event.requester?.accountId,
|
||||
to: event.requester?.to,
|
||||
threadId: event.requester?.threadId,
|
||||
requesterSessionKey: ctx.requesterSessionKey,
|
||||
});
|
||||
if (!conversation) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Feishu current-conversation binding is only available in direct messages or topic conversations.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = manager.bindConversation({
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: event.childSessionKey,
|
||||
metadata: {
|
||||
agentId: event.agentId,
|
||||
label: event.label,
|
||||
boundBy: "system",
|
||||
deliveryTo: event.requester?.to,
|
||||
deliveryThreadId:
|
||||
event.requester?.threadId != null && event.requester.threadId !== ""
|
||||
? String(event.requester.threadId)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Unable to bind this Feishu conversation to the spawned subagent session. Session mode is unavailable for this target.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "ok" as const,
|
||||
threadBindingReady: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: `Feishu conversation bind failed: ${summarizeError(err)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
api.on("subagent_delivery_target", (event) => {
|
||||
if (!event.expectsCompletionMessage) {
|
||||
return;
|
||||
}
|
||||
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
|
||||
if (requesterChannel !== "feishu") {
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = resolveMatchingChildBinding({
|
||||
accountId: event.requesterOrigin?.accountId,
|
||||
childSessionKey: event.childSessionKey,
|
||||
requesterSessionKey: event.requesterSessionKey,
|
||||
requesterOrigin: {
|
||||
to: event.requesterOrigin?.to,
|
||||
threadId: event.requesterOrigin?.threadId,
|
||||
},
|
||||
});
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
origin: resolveFeishuDeliveryOrigin({
|
||||
conversationId: binding.conversationId,
|
||||
parentConversationId: binding.parentConversationId,
|
||||
accountId: binding.accountId,
|
||||
deliveryTo: binding.deliveryTo,
|
||||
deliveryThreadId: binding.deliveryThreadId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
api.on("subagent_ended", (event) => {
|
||||
const manager = getFeishuThreadBindingManager(event.accountId);
|
||||
manager?.unbindBySessionKey(event.targetSessionKey);
|
||||
});
|
||||
}
|
||||
94
extensions/feishu/src/thread-bindings.test.ts
Normal file
94
extensions/feishu/src/thread-bindings.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { __testing, createFeishuThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
describe("Feishu thread bindings", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetFeishuThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("registers current-placement adapter capabilities for Feishu", () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
|
||||
expect(
|
||||
getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
}),
|
||||
).toEqual({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
});
|
||||
});
|
||||
|
||||
it("binds and resolves a Feishu topic conversation", async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
|
||||
const binding = await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(binding.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
metadata: expect.objectContaining({
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("clears account-scoped bindings when the manager stops", async () => {
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
manager.stop();
|
||||
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
316
extensions/feishu/src/thread-bindings.ts
Normal file
316
extensions/feishu/src/thread-bindings.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js";
|
||||
import {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "../../../src/channels/thread-bindings-policy.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
normalizeAccountId,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../../src/routing/session-key.js";
|
||||
import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js";
|
||||
|
||||
type FeishuBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
type FeishuThreadBindingRecord = {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
deliveryTo?: string;
|
||||
deliveryThreadId?: string;
|
||||
targetKind: FeishuBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
};
|
||||
|
||||
type FeishuThreadBindingManager = {
|
||||
accountId: string;
|
||||
getByConversationId: (conversationId: string) => FeishuThreadBindingRecord | undefined;
|
||||
listBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[];
|
||||
bindConversation: (params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
targetKind: BindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => FeishuThreadBindingRecord | null;
|
||||
touchConversation: (conversationId: string, at?: number) => FeishuThreadBindingRecord | null;
|
||||
unbindConversation: (conversationId: string) => FeishuThreadBindingRecord | null;
|
||||
unbindBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[];
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type FeishuThreadBindingsState = {
|
||||
managersByAccountId: Map<string, FeishuThreadBindingManager>;
|
||||
bindingsByAccountConversation: Map<string, FeishuThreadBindingRecord>;
|
||||
};
|
||||
|
||||
const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState");
|
||||
const state = resolveGlobalSingleton<FeishuThreadBindingsState>(
|
||||
FEISHU_THREAD_BINDINGS_STATE_KEY,
|
||||
() => ({
|
||||
managersByAccountId: new Map(),
|
||||
bindingsByAccountConversation: new Map(),
|
||||
}),
|
||||
);
|
||||
|
||||
const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId;
|
||||
const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation;
|
||||
|
||||
function resolveBindingKey(params: { accountId: string; conversationId: string }): string {
|
||||
return `${params.accountId}:${params.conversationId}`;
|
||||
}
|
||||
|
||||
function toSessionBindingTargetKind(raw: FeishuBindingTargetKind): BindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "session";
|
||||
}
|
||||
|
||||
function toFeishuTargetKind(raw: BindingTargetKind): FeishuBindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: FeishuThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
): SessionBindingRecord {
|
||||
const idleExpiresAt =
|
||||
defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
|
||||
const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
|
||||
const expiresAt =
|
||||
idleExpiresAt != null && maxAgeExpiresAt != null
|
||||
? Math.min(idleExpiresAt, maxAgeExpiresAt)
|
||||
: (idleExpiresAt ?? maxAgeExpiresAt);
|
||||
return {
|
||||
bindingId: resolveBindingKey({
|
||||
accountId: record.accountId,
|
||||
conversationId: record.conversationId,
|
||||
}),
|
||||
targetSessionKey: record.targetSessionKey,
|
||||
targetKind: toSessionBindingTargetKind(record.targetKind),
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: record.accountId,
|
||||
conversationId: record.conversationId,
|
||||
parentConversationId: record.parentConversationId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt,
|
||||
metadata: {
|
||||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
boundBy: record.boundBy,
|
||||
deliveryTo: record.deliveryTo,
|
||||
deliveryThreadId: record.deliveryThreadId,
|
||||
lastActivityAt: record.lastActivityAt,
|
||||
idleTimeoutMs: defaults.idleTimeoutMs,
|
||||
maxAgeMs: defaults.maxAgeMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFeishuThreadBindingManager(params: {
|
||||
accountId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}): FeishuThreadBindingManager {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
|
||||
cfg: params.cfg,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
});
|
||||
const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
|
||||
cfg: params.cfg,
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
});
|
||||
|
||||
const manager: FeishuThreadBindingManager = {
|
||||
accountId,
|
||||
getByConversationId: (conversationId) =>
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })),
|
||||
listBySessionKey: (targetSessionKey) =>
|
||||
[...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter(
|
||||
(record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey,
|
||||
),
|
||||
bindConversation: ({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
metadata,
|
||||
}) => {
|
||||
const normalizedConversationId = conversationId.trim();
|
||||
if (!normalizedConversationId || !targetSessionKey.trim()) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const record: FeishuThreadBindingRecord = {
|
||||
accountId,
|
||||
conversationId: normalizedConversationId,
|
||||
parentConversationId: parentConversationId?.trim() || undefined,
|
||||
deliveryTo:
|
||||
typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim()
|
||||
? metadata.deliveryTo.trim()
|
||||
: undefined,
|
||||
deliveryThreadId:
|
||||
typeof metadata?.deliveryThreadId === "string" && metadata.deliveryThreadId.trim()
|
||||
? metadata.deliveryThreadId.trim()
|
||||
: undefined,
|
||||
targetKind: toFeishuTargetKind(targetKind),
|
||||
targetSessionKey: targetSessionKey.trim(),
|
||||
agentId:
|
||||
typeof metadata?.agentId === "string" && metadata.agentId.trim()
|
||||
? metadata.agentId.trim()
|
||||
: resolveAgentIdFromSessionKey(targetSessionKey),
|
||||
label:
|
||||
typeof metadata?.label === "string" && metadata.label.trim()
|
||||
? metadata.label.trim()
|
||||
: undefined,
|
||||
boundBy:
|
||||
typeof metadata?.boundBy === "string" && metadata.boundBy.trim()
|
||||
? metadata.boundBy.trim()
|
||||
: undefined,
|
||||
boundAt: now,
|
||||
lastActivityAt: now,
|
||||
};
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(
|
||||
resolveBindingKey({ accountId, conversationId: normalizedConversationId }),
|
||||
record,
|
||||
);
|
||||
return record;
|
||||
},
|
||||
touchConversation: (conversationId, at = Date.now()) => {
|
||||
const key = resolveBindingKey({ accountId, conversationId });
|
||||
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
||||
if (!existingRecord) {
|
||||
return null;
|
||||
}
|
||||
const updated = { ...existingRecord, lastActivityAt: at };
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated);
|
||||
return updated;
|
||||
},
|
||||
unbindConversation: (conversationId) => {
|
||||
const key = resolveBindingKey({ accountId, conversationId });
|
||||
const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key);
|
||||
if (!existingRecord) {
|
||||
return null;
|
||||
}
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||
return existingRecord;
|
||||
},
|
||||
unbindBySessionKey: (targetSessionKey) => {
|
||||
const removed: FeishuThreadBindingRecord[] = [];
|
||||
for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) {
|
||||
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
||||
continue;
|
||||
}
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(
|
||||
resolveBindingKey({ accountId, conversationId: record.conversationId }),
|
||||
);
|
||||
removed.push(record);
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
stop: () => {
|
||||
for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) {
|
||||
if (key.startsWith(`${accountId}:`)) {
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key);
|
||||
}
|
||||
}
|
||||
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
||||
unregisterSessionBindingAdapter({ channel: "feishu", accountId });
|
||||
},
|
||||
};
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
capabilities: {
|
||||
placements: ["current"],
|
||||
},
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "feishu" || input.placement === "child") {
|
||||
return null;
|
||||
}
|
||||
const bound = manager.bindConversation({
|
||||
conversationId: input.conversation.conversationId,
|
||||
parentConversationId: input.conversation.parentConversationId,
|
||||
targetKind: input.targetKind,
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
manager
|
||||
.listBySessionKey(targetSessionKey)
|
||||
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
|
||||
resolveByConversation: (ref) => {
|
||||
if (ref.channel !== "feishu") {
|
||||
return null;
|
||||
}
|
||||
const found = manager.getByConversationId(ref.conversationId);
|
||||
return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
|
||||
},
|
||||
touch: (bindingId, at) => {
|
||||
const conversationId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId,
|
||||
bindingId,
|
||||
});
|
||||
if (conversationId) {
|
||||
manager.touchConversation(conversationId, at);
|
||||
}
|
||||
},
|
||||
unbind: async (input) => {
|
||||
if (input.targetSessionKey?.trim()) {
|
||||
return manager
|
||||
.unbindBySessionKey(input.targetSessionKey.trim())
|
||||
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
|
||||
}
|
||||
const conversationId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId,
|
||||
bindingId: input.bindingId,
|
||||
});
|
||||
if (!conversationId) {
|
||||
return [];
|
||||
}
|
||||
const removed = manager.unbindConversation(conversationId);
|
||||
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
|
||||
},
|
||||
});
|
||||
|
||||
MANAGERS_BY_ACCOUNT_ID.set(accountId, manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function getFeishuThreadBindingManager(
|
||||
accountId?: string,
|
||||
): FeishuThreadBindingManager | null {
|
||||
return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetFeishuThreadBindingsForTests() {
|
||||
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
||||
manager.stop();
|
||||
}
|
||||
MANAGERS_BY_ACCOUNT_ID.clear();
|
||||
BINDINGS_BY_ACCOUNT_CONVERSATION.clear();
|
||||
},
|
||||
};
|
||||
97
extensions/googlechat/src/auth.test.ts
Normal file
97
extensions/googlechat/src/auth.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifyIdToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: class {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
},
|
||||
}));
|
||||
|
||||
const { verifyGoogleChatRequest } = await import("./auth.js");
|
||||
|
||||
function mockTicket(payload: Record<string, unknown>) {
|
||||
mocks.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyGoogleChatRequest", () => {
|
||||
beforeEach(() => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
});
|
||||
|
||||
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
||||
mockTicket({
|
||||
email: "chat@system.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when no principal binding is configured", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missing add-on principal binding",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on tokens only when the bound principal matches", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when the bound principal does not match", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -94,6 +94,7 @@ export async function verifyGoogleChatRequest(params: {
|
||||
bearer?: string | null;
|
||||
audienceType?: GoogleChatAudienceType | null;
|
||||
audience?: string | null;
|
||||
expectedAddOnPrincipal?: string | null;
|
||||
}): Promise<{ ok: boolean; reason?: string }> {
|
||||
const bearer = params.bearer?.trim();
|
||||
if (!bearer) {
|
||||
@ -112,10 +113,32 @@ export async function verifyGoogleChatRequest(params: {
|
||||
audience,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
const email = payload?.email ?? "";
|
||||
const ok =
|
||||
payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email));
|
||||
return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` };
|
||||
const email = String(payload?.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!payload?.email_verified) {
|
||||
return { ok: false, reason: "email not verified" };
|
||||
}
|
||||
if (email === CHAT_ISSUER) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (!ADDON_ISSUER_PATTERN.test(email)) {
|
||||
return { ok: false, reason: `invalid issuer: ${email}` };
|
||||
}
|
||||
const expectedAddOnPrincipal = params.expectedAddOnPrincipal?.trim().toLowerCase();
|
||||
if (!expectedAddOnPrincipal) {
|
||||
return { ok: false, reason: "missing add-on principal binding" };
|
||||
}
|
||||
const tokenPrincipal = String(payload?.sub ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!tokenPrincipal || tokenPrincipal !== expectedAddOnPrincipal) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `unexpected add-on principal: ${tokenPrincipal || "<missing>"}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||
}
|
||||
|
||||
@ -132,6 +132,7 @@ export function createGoogleChatWebhookRequestHandler(params: {
|
||||
bearer: headerBearer,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
||||
});
|
||||
return verification.ok;
|
||||
},
|
||||
@ -166,6 +167,7 @@ export function createGoogleChatWebhookRequestHandler(params: {
|
||||
bearer: parsed.addOnBearerToken,
|
||||
audienceType: target.audienceType,
|
||||
audience: target.audience,
|
||||
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
||||
});
|
||||
return verification.ok;
|
||||
},
|
||||
|
||||
@ -738,6 +738,37 @@ describe("createMattermostInteractionHandler", () => {
|
||||
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||
});
|
||||
|
||||
it("blocks button dispatch when the sender is not allowed for the action", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const handleInteraction = vi.fn();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: {
|
||||
request: async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : createActionPost(),
|
||||
} as unknown as MattermostClient,
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
authorizeButtonClick: async () => ({
|
||||
ok: false,
|
||||
response: {
|
||||
ephemeral_text: "blocked",
|
||||
},
|
||||
}),
|
||||
handleInteraction,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain("blocked");
|
||||
expect(handleInteraction).not.toHaveBeenCalled();
|
||||
expect(dispatchButtonClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards fetched post threading metadata to session and button callbacks", async () => {
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
setMattermostRuntime({
|
||||
|
||||
@ -37,6 +37,10 @@ export type MattermostInteractionResponse = {
|
||||
ephemeral_text?: string;
|
||||
};
|
||||
|
||||
export type MattermostInteractionAuthorizationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; statusCode?: number; response?: MattermostInteractionResponse };
|
||||
|
||||
export type MattermostInteractiveButtonInput = {
|
||||
id?: string;
|
||||
callback_data?: string;
|
||||
@ -404,6 +408,10 @@ export function createMattermostInteractionHandler(params: {
|
||||
context: Record<string, unknown>;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionResponse | null>;
|
||||
authorizeButtonClick?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionAuthorizationResult>;
|
||||
dispatchButtonClick?: (opts: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
@ -566,6 +574,33 @@ export function createMattermostInteractionHandler(params: {
|
||||
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||
);
|
||||
|
||||
if (params.authorizeButtonClick) {
|
||||
try {
|
||||
const authorization = await params.authorizeButtonClick({
|
||||
payload,
|
||||
post: originalPost,
|
||||
});
|
||||
if (!authorization.ok) {
|
||||
res.statusCode = authorization.statusCode ?? 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
authorization.response ?? {
|
||||
ephemeral_text: "You are not allowed to use this action here.",
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.handleInteraction) {
|
||||
try {
|
||||
const response = await params.handleInteraction({
|
||||
|
||||
@ -567,6 +567,45 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
trustedProxies: cfg.gateway?.trustedProxies,
|
||||
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
||||
handleInteraction: handleModelPickerInteraction,
|
||||
authorizeButtonClick: async ({ payload, post }) => {
|
||||
const channelInfo = await resolveChannelInfo(payload.channel_id);
|
||||
const isDirect = channelInfo?.type?.trim().toUpperCase() === "D";
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const decision = authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId: payload.user_id,
|
||||
senderName: payload.user_name ?? "",
|
||||
channelId: payload.channel_id,
|
||||
channelInfo,
|
||||
storeAllowFrom: isDirect
|
||||
? await readStoreAllowFromForDmPolicy({
|
||||
provider: "mattermost",
|
||||
accountId: account.accountId,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
})
|
||||
: undefined,
|
||||
allowTextCommands,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
if (decision.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
response: {
|
||||
update: {
|
||||
message: post.message ?? "",
|
||||
props: post.props as Record<string, unknown> | undefined,
|
||||
},
|
||||
ephemeral_text: `OpenClaw ignored this action for ${decision.roomLabel}.`,
|
||||
},
|
||||
};
|
||||
},
|
||||
resolveSessionKey: async ({ channelId, userId, post }) => {
|
||||
const channelInfo = await resolveChannelInfo(channelId);
|
||||
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
||||
|
||||
@ -81,4 +81,77 @@ describe("nextcloud-talk inbound authz", () => {
|
||||
});
|
||||
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches group rooms by token instead of colliding room names", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => []);
|
||||
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
||||
|
||||
setNextcloudTalkRuntime({
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => false,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns: () => false,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const message: NextcloudTalkInboundMessage = {
|
||||
messageId: "m-2",
|
||||
roomToken: "room-attacker",
|
||||
roomName: "Room Trusted",
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
text: "hello",
|
||||
mediaType: "text/plain",
|
||||
timestamp: Date.now(),
|
||||
isGroupChat: true,
|
||||
};
|
||||
|
||||
const account: ResolvedNextcloudTalkAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "",
|
||||
secret: "",
|
||||
secretSource: "none",
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["trusted-user"],
|
||||
rooms: {
|
||||
"room-trusted": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleNextcloudTalkInbound({
|
||||
message,
|
||||
account,
|
||||
config: {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["trusted-user"],
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -114,7 +114,6 @@ export async function handleNextcloudTalkInbound(params: {
|
||||
const roomMatch = resolveNextcloudTalkRoomMatch({
|
||||
rooms: account.config.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
const roomConfig = roomMatch.roomConfig;
|
||||
if (isGroup && !roomMatch.allowed) {
|
||||
|
||||
@ -57,16 +57,10 @@ export type NextcloudTalkRoomMatch = {
|
||||
export function resolveNextcloudTalkRoomMatch(params: {
|
||||
rooms?: Record<string, NextcloudTalkRoomConfig>;
|
||||
roomToken: string;
|
||||
roomName?: string | null;
|
||||
}): NextcloudTalkRoomMatch {
|
||||
const rooms = params.rooms ?? {};
|
||||
const allowlistConfigured = Object.keys(rooms).length > 0;
|
||||
const roomName = params.roomName?.trim() || undefined;
|
||||
const roomCandidates = buildChannelKeyCandidates(
|
||||
params.roomToken,
|
||||
roomName,
|
||||
roomName ? normalizeChannelSlug(roomName) : undefined,
|
||||
);
|
||||
const roomCandidates = buildChannelKeyCandidates(params.roomToken);
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: rooms,
|
||||
keys: roomCandidates,
|
||||
@ -101,11 +95,9 @@ export function resolveNextcloudTalkGroupToolPolicy(
|
||||
if (!roomToken) {
|
||||
return undefined;
|
||||
}
|
||||
const roomName = params.groupChannel?.trim() || undefined;
|
||||
const match = resolveNextcloudTalkRoomMatch({
|
||||
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
}
|
||||
|
||||
@ -160,6 +160,13 @@ describe("checkTwitchAccessControl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks everyone when allowFrom is explicitly empty", () => {
|
||||
expectAllowFromBlocked({
|
||||
allowFrom: [],
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks messages without userId", () => {
|
||||
expectAllowFromBlocked({
|
||||
allowFrom: ["123456"],
|
||||
|
||||
@ -48,8 +48,14 @@ export function checkTwitchAccessControl(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (account.allowFrom && account.allowFrom.length > 0) {
|
||||
if (account.allowFrom !== undefined) {
|
||||
const allowFrom = account.allowFrom;
|
||||
if (allowFrom.length === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender is not in allowFrom allowlist",
|
||||
};
|
||||
}
|
||||
const senderId = message.userId;
|
||||
|
||||
if (!senderId) {
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
resolveWhatsAppGroupIntroHint,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
resolveWhatsAppMentionStripPatterns,
|
||||
resolveWhatsAppMentionStripRegexes,
|
||||
WhatsAppConfigSchema,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
@ -214,7 +214,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx),
|
||||
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
|
||||
},
|
||||
commands: {
|
||||
enforceOwnerForCommands: true,
|
||||
|
||||
@ -254,6 +254,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("handles append messages by marking them read but skipping auto-reply", async () => {
|
||||
const { onMessage, listener, sock } = await openInboxMonitor();
|
||||
const staleTs = Math.floor(Date.now() / 1000) - 300;
|
||||
|
||||
const upsert = {
|
||||
type: "append",
|
||||
@ -265,9 +266,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "old message" },
|
||||
// Use a timestamp well outside the recency grace window (> 60 s before
|
||||
// connection) so the append-recency filter correctly skips auto-reply.
|
||||
messageTimestamp: nowSeconds(-120_000),
|
||||
messageTimestamp: staleTs,
|
||||
pushName: "History Sender",
|
||||
},
|
||||
],
|
||||
|
||||
@ -4,8 +4,20 @@ export function runWatchMain(params?: {
|
||||
args: string[],
|
||||
options: unknown,
|
||||
) => {
|
||||
kill?: (signal?: NodeJS.Signals | number) => void;
|
||||
on: (event: "exit", cb: (code: number | null, signal: string | null) => void) => void;
|
||||
};
|
||||
createWatcher?: (
|
||||
paths: string[],
|
||||
options: {
|
||||
ignoreInitial: boolean;
|
||||
ignored: (watchPath: string) => boolean;
|
||||
},
|
||||
) => {
|
||||
on: (event: "add" | "change" | "unlink" | "error", cb: (arg?: unknown) => void) => void;
|
||||
close?: () => Promise<void> | void;
|
||||
};
|
||||
watchPaths?: string[];
|
||||
process?: NodeJS.Process;
|
||||
cwd?: string;
|
||||
args?: string[];
|
||||
|
||||
@ -2,16 +2,24 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import chokidar from "chokidar";
|
||||
import { runNodeWatchedPaths } from "./run-node.mjs";
|
||||
|
||||
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
|
||||
const WATCH_RESTART_SIGNAL = "SIGTERM";
|
||||
|
||||
const buildWatchArgs = (args) => [
|
||||
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||
"--watch-preserve-output",
|
||||
WATCH_NODE_RUNNER,
|
||||
...args,
|
||||
];
|
||||
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
|
||||
|
||||
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
|
||||
|
||||
const isIgnoredWatchPath = (filePath) => {
|
||||
const normalizedPath = normalizePath(filePath);
|
||||
return (
|
||||
normalizedPath.endsWith(".test.ts") ||
|
||||
normalizedPath.endsWith(".test.tsx") ||
|
||||
normalizedPath.endsWith("test-helpers.ts")
|
||||
);
|
||||
};
|
||||
|
||||
export async function runWatchMain(params = {}) {
|
||||
const deps = {
|
||||
@ -21,6 +29,9 @@ export async function runWatchMain(params = {}) {
|
||||
args: params.args ?? process.argv.slice(2),
|
||||
env: params.env ? { ...params.env } : { ...process.env },
|
||||
now: params.now ?? Date.now,
|
||||
createWatcher:
|
||||
params.createWatcher ?? ((watchPaths, options) => chokidar.watch(watchPaths, options)),
|
||||
watchPaths: params.watchPaths ?? runNodeWatchedPaths,
|
||||
};
|
||||
|
||||
const childEnv = { ...deps.env };
|
||||
@ -31,54 +42,96 @@ export async function runWatchMain(params = {}) {
|
||||
childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" ");
|
||||
}
|
||||
|
||||
const watchProcess = deps.spawn(deps.process.execPath, buildWatchArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const settle = (resolve, code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
onSigInt = () => {
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
let settled = false;
|
||||
let shuttingDown = false;
|
||||
let restartRequested = false;
|
||||
let watchProcess = null;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const watcher = deps.createWatcher(deps.watchPaths, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
|
||||
});
|
||||
|
||||
const settle = (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settle(resolve, 130);
|
||||
settled = true;
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
watcher.close?.().catch?.(() => {});
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const startRunner = () => {
|
||||
watchProcess = deps.spawn(deps.process.execPath, buildRunnerArgs(deps.args), {
|
||||
cwd: deps.cwd,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
watchProcess.on("exit", () => {
|
||||
watchProcess = null;
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
if (restartRequested) {
|
||||
restartRequested = false;
|
||||
startRunner();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const requestRestart = (changedPath) => {
|
||||
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
|
||||
return;
|
||||
}
|
||||
if (!watchProcess) {
|
||||
startRunner();
|
||||
return;
|
||||
}
|
||||
restartRequested = true;
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
};
|
||||
|
||||
watcher.on("add", requestRestart);
|
||||
watcher.on("change", requestRestart);
|
||||
watcher.on("unlink", requestRestart);
|
||||
watcher.on("error", () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(1);
|
||||
});
|
||||
|
||||
startRunner();
|
||||
|
||||
onSigInt = () => {
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(130);
|
||||
};
|
||||
onSigTerm = () => {
|
||||
if (typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill("SIGTERM");
|
||||
shuttingDown = true;
|
||||
if (watchProcess && typeof watchProcess.kill === "function") {
|
||||
watchProcess.kill(WATCH_RESTART_SIGNAL);
|
||||
}
|
||||
settle(resolve, 143);
|
||||
settle(143);
|
||||
};
|
||||
|
||||
deps.process.on("SIGINT", onSigInt);
|
||||
deps.process.on("SIGTERM", onSigTerm);
|
||||
|
||||
watchProcess.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
settle(resolve, 1);
|
||||
return;
|
||||
}
|
||||
settle(resolve, code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -366,6 +366,47 @@ describe("resolvePermissionRequest", () => {
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves safe tools when rawInput is the only identity hint", async () => {
|
||||
const prompt = vi.fn(async () => true);
|
||||
const res = await resolvePermissionRequest(
|
||||
makePermissionRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tool-raw-only",
|
||||
title: "Searching files",
|
||||
status: "pending",
|
||||
rawInput: {
|
||||
name: "search",
|
||||
query: "foo",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ prompt, log: () => {} },
|
||||
);
|
||||
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts when raw input spoofs a safe tool name for a dangerous title", async () => {
|
||||
const prompt = vi.fn(async () => false);
|
||||
const res = await resolvePermissionRequest(
|
||||
makePermissionRequest({
|
||||
toolCall: {
|
||||
toolCallId: "tool-exec-spoof",
|
||||
title: "exec: cat /etc/passwd",
|
||||
status: "pending",
|
||||
rawInput: {
|
||||
command: "cat /etc/passwd",
|
||||
name: "search",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ prompt, log: () => {} },
|
||||
);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledWith(undefined, "exec: cat /etc/passwd");
|
||||
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject" } });
|
||||
});
|
||||
|
||||
it("prompts for read outside cwd scope", async () => {
|
||||
const prompt = vi.fn(async () => false);
|
||||
const res = await resolvePermissionRequest(
|
||||
|
||||
@ -104,7 +104,22 @@ function resolveToolNameForPermission(params: RequestPermissionRequest): string
|
||||
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
|
||||
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
|
||||
const fromTitle = parseToolNameFromTitle(toolCall?.title);
|
||||
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
|
||||
const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined;
|
||||
const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined;
|
||||
const titleName = fromTitle;
|
||||
if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) {
|
||||
return undefined;
|
||||
}
|
||||
if (metaName && titleName && metaName !== titleName) {
|
||||
return undefined;
|
||||
}
|
||||
if (rawInputName && metaName && rawInputName !== metaName) {
|
||||
return undefined;
|
||||
}
|
||||
if (rawInputName && titleName && rawInputName !== titleName) {
|
||||
return undefined;
|
||||
}
|
||||
return metaName ?? titleName ?? rawInputName;
|
||||
}
|
||||
|
||||
function extractPathFromToolTitle(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
@ -21,12 +22,23 @@ import {
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "discord" || normalized === "telegram") {
|
||||
if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
||||
const trimmed = conversationId.trim();
|
||||
if (!trimmed || trimmed.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) {
|
||||
@ -122,14 +134,23 @@ function resolveConfiguredBindingRecord(params: {
|
||||
bindings: AgentAcpBinding[];
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
selectConversation: (
|
||||
binding: AgentAcpBinding,
|
||||
) => { conversationId: string; parentConversationId?: string } | null;
|
||||
selectConversation: (binding: AgentAcpBinding) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
let wildcardMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
let exactMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
for (const binding of params.bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
|
||||
@ -146,23 +167,40 @@ function resolveConfiguredBindingRecord(params: {
|
||||
if (!conversation) {
|
||||
continue;
|
||||
}
|
||||
const matchPriority = conversation.matchPriority ?? 0;
|
||||
if (accountMatchPriority === 2) {
|
||||
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
|
||||
exactMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
|
||||
wildcardMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (exactMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
binding,
|
||||
conversationId: exactMatch.conversationId,
|
||||
parentConversationId: exactMatch.parentConversationId,
|
||||
binding: exactMatch.binding,
|
||||
});
|
||||
if (accountMatchPriority === 2) {
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = { binding, ...conversation };
|
||||
}
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
@ -228,6 +266,42 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "feishu",
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: targetParsed.canonicalConversationId,
|
||||
// Session-key recovery deliberately collapses sender-scoped topic bindings onto the
|
||||
// canonical topic conversation id so `group_topic` and `group_topic_sender` reuse
|
||||
// the same configured ACP session identity.
|
||||
parentConversationId:
|
||||
targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender"
|
||||
? targetParsed.chatId
|
||||
: undefined,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const parsedTopic = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
@ -334,5 +408,63 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (channel === "feishu") {
|
||||
const parsed = parseFeishuConversationId({
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
if (
|
||||
!parsed ||
|
||||
(parsed.scope !== "group_topic" &&
|
||||
parsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
const targetParsed = parseFeishuConversationId({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (
|
||||
!targetParsed ||
|
||||
(targetParsed.scope !== "group_topic" &&
|
||||
targetParsed.scope !== "group_topic_sender" &&
|
||||
!isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const matchesCanonicalConversation =
|
||||
targetParsed.canonicalConversationId === parsed.canonicalConversationId;
|
||||
const matchesParentTopicForSenderScopedConversation =
|
||||
parsed.scope === "group_topic_sender" &&
|
||||
targetParsed.scope === "group_topic" &&
|
||||
parsed.chatId === targetParsed.chatId &&
|
||||
parsed.topicId === targetParsed.topicId;
|
||||
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: matchesParentTopicForSenderScopedConversation
|
||||
? targetParsed.canonicalConversationId
|
||||
: parsed.canonicalConversationId,
|
||||
parentConversationId:
|
||||
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
|
||||
? parsed.chatId
|
||||
: undefined,
|
||||
matchPriority: matchesCanonicalConversation ? 2 : 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -90,6 +90,27 @@ function createTelegramGroupBinding(params: {
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function createFeishuBinding(params: {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
accountId?: string;
|
||||
acp?: Record<string, unknown>;
|
||||
}): ConfiguredBinding {
|
||||
return {
|
||||
type: "acp",
|
||||
agentId: params.agentId,
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: params.accountId ?? defaultDiscordAccountId,
|
||||
peer: {
|
||||
kind: params.conversationId.includes(":topic:") ? "group" : "direct",
|
||||
id: params.conversationId,
|
||||
},
|
||||
},
|
||||
...(params.acp ? { acp: params.acp } : {}),
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||
return resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
@ -205,6 +226,34 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("prefers sender-scoped Feishu bindings over topic inheritance", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
accountId: "work",
|
||||
}),
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
accountId: "work",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe(
|
||||
"oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
||||
);
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("prefers exact account binding over wildcard for the same discord conversation", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
@ -284,6 +333,128 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves Feishu DM bindings using direct peer ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "ou_user_1",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_user_1",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.channel).toBe("feishu");
|
||||
expect(resolved?.spec.conversationId).toBe("ou_user_1");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
||||
});
|
||||
|
||||
it("resolves Feishu DM bindings using user_id fallback peer ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "user_123",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.channel).toBe("feishu");
|
||||
expect(resolved?.spec.conversationId).toBe("user_123");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
||||
});
|
||||
|
||||
it("resolves Feishu topic bindings with parent chat ids", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat");
|
||||
});
|
||||
|
||||
it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
expect(resolved?.spec.backend).toBe("acpx");
|
||||
expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
|
||||
it("rejects non-matching Feishu topic roots", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_other_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects Feishu non-topic group ACP bindings", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "oc_group_chat",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("applies agent runtime ACP defaults for bound conversations", () => {
|
||||
const cfg = createCfgWithBindings(
|
||||
[
|
||||
@ -365,6 +536,31 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
|
||||
expect(spec?.backend).toBe("exact");
|
||||
});
|
||||
|
||||
it("maps a configured Feishu user_id DM binding session key back to its spec", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
createFeishuBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "user_123",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
|
||||
expect(spec?.channel).toBe("feishu");
|
||||
expect(spec?.conversationId).toBe("user_123");
|
||||
expect(spec?.agentId).toBe("codex");
|
||||
expect(spec?.backend).toBe("acpx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfiguredAcpSessionKey", () => {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram";
|
||||
export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu";
|
||||
|
||||
export type ConfiguredAcpBindingSpec = {
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
|
||||
23
src/agents/model-id-normalization.ts
Normal file
23
src/agents/model-id-normalization.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// Keep model ID normalization dependency-free so config parsing and other
|
||||
// startup-only paths do not pull in provider discovery or plugin loading.
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") {
|
||||
return "gemini-3-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-flash-lite") {
|
||||
return "gemini-3.1-flash-lite-preview";
|
||||
}
|
||||
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
|
||||
// non-existent Gemini Flash preview ID. Google's current Flash text model is
|
||||
// `gemini-3-flash-preview`.
|
||||
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@ -14,8 +14,8 @@ import {
|
||||
} from "./agent-scope.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.js";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
|
||||
import { normalizeGoogleModelId } from "./models-config.providers.js";
|
||||
|
||||
const log = createSubsystemLogger("model-selection");
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import {
|
||||
normalizeAntigravityModelId,
|
||||
normalizeGoogleModelId,
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
} from "./models-config.providers.js";
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
buildCloudflareAiGatewayModelDefinition,
|
||||
resolveCloudflareAiGatewayBaseUrl,
|
||||
} from "./cloudflare-ai-gateway.js";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import {
|
||||
buildHuggingfaceProvider,
|
||||
buildKilocodeProviderWithDiscovery,
|
||||
@ -70,6 +71,7 @@ import {
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
|
||||
export { normalizeGoogleModelId };
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
@ -223,28 +225,6 @@ function resolveApiKeyFromProfiles(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") {
|
||||
return "gemini-3-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3.1-flash-lite") {
|
||||
return "gemini-3.1-flash-lite-preview";
|
||||
}
|
||||
// Preserve compatibility with earlier OpenClaw docs/config that pointed at a
|
||||
// non-existent Gemini Flash preview ID. Google's current Flash text model is
|
||||
// `gemini-3-flash-preview`.
|
||||
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
|
||||
|
||||
export function normalizeAntigravityModelId(id: string): string {
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
stripMentions,
|
||||
} from "./reply/mentions.js";
|
||||
import { initSessionState } from "./reply/session.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
||||
@ -394,10 +395,10 @@ describe("initSessionState BodyStripped", () => {
|
||||
});
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
it("builds regexes and skips invalid or unsafe patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(invalid"] },
|
||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(invalid", "(a+)+$"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
@ -435,6 +436,20 @@ describe("mention helpers", () => {
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
|
||||
it("strips safe mention patterns and ignores unsafe ones", () => {
|
||||
const stripped = stripMentions("openclaw " + "a".repeat(28) + "!", {} as MsgContext, {
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(a+)+$"] },
|
||||
},
|
||||
});
|
||||
expect(stripped).toBe(`${"a".repeat(28)}!`);
|
||||
});
|
||||
|
||||
it("strips provider mention regexes without config compilation", () => {
|
||||
const stripped = stripMentions("<@12345> hello", { Provider: "discord" } as MsgContext, {});
|
||||
expect(stripped).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
|
||||
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const callGatewayMock = vi.fn();
|
||||
@ -118,7 +119,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "telegram";
|
||||
channel: "discord" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@ -243,7 +244,7 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "telegram";
|
||||
channel?: "discord" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
@ -256,21 +257,28 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
input.placement === "child" ? "thread-created" : input.conversation.conversationId;
|
||||
const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
|
||||
const channel = input.conversation.channel ?? "discord";
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation:
|
||||
channel === "discord"
|
||||
const conversation =
|
||||
channel === "discord"
|
||||
? {
|
||||
channel: "discord" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: channel === "feishu"
|
||||
? {
|
||||
channel: "discord",
|
||||
channel: "feishu" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: {
|
||||
channel: "telegram",
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
},
|
||||
};
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation,
|
||||
metadata: { boundBy, webhookId: "wh-1" },
|
||||
});
|
||||
}
|
||||
@ -350,6 +358,41 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
|
||||
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "user:ou_sender_1",
|
||||
AccountId: "default",
|
||||
SenderId: "ou_sender_1",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runFeishuDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createFeishuDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runInternalAcpCommand(params: {
|
||||
commandBody: string;
|
||||
scopes: string[];
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
const commandParams = buildCommandTestParams(params.commandBody, params.cfg ?? baseCfg, {
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
|
||||
OriginatingTo: "webchat:conversation-1",
|
||||
GatewayClientScopes: params.scopes,
|
||||
});
|
||||
commandParams.command.channel = INTERNAL_MESSAGE_CHANNEL;
|
||||
commandParams.command.senderId = "user-1";
|
||||
commandParams.command.senderIsOwner = true;
|
||||
return handleAcpCommand(commandParams, true);
|
||||
}
|
||||
|
||||
describe("/acp command", () => {
|
||||
beforeEach(() => {
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
@ -553,6 +596,23 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
|
||||
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
|
||||
expect(result?.reply?.text).toContain("Bound this thread to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "ou_sender_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp spawn");
|
||||
|
||||
@ -783,6 +843,64 @@ describe("/acp command", () => {
|
||||
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
|
||||
});
|
||||
|
||||
it("blocks mutating /acp actions for internal operator.write clients", async () => {
|
||||
const result = await runInternalAcpCommand({
|
||||
commandBody: "/acp set-mode plan",
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("requires operator.admin");
|
||||
});
|
||||
|
||||
it("blocks /acp status for internal operator.write clients", async () => {
|
||||
const result = await runInternalAcpCommand({
|
||||
commandBody: "/acp status",
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("requires operator.admin");
|
||||
});
|
||||
|
||||
it("keeps read-only /acp actions available to internal operator.write clients", async () => {
|
||||
hoisted.listAcpSessionEntriesMock.mockResolvedValue([
|
||||
createAcpSessionEntry({
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "runtime-1",
|
||||
agentSessionId: "session-1",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runInternalAcpCommand({
|
||||
commandBody: "/acp sessions",
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toContain("ACP sessions");
|
||||
});
|
||||
|
||||
it("allows mutating /acp actions for internal operator.admin clients", async () => {
|
||||
mockBoundThreadSession();
|
||||
|
||||
const result = await runInternalAcpCommand({
|
||||
commandBody: "/acp set-mode plan",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(hoisted.setModeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "plan",
|
||||
}),
|
||||
);
|
||||
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
|
||||
});
|
||||
|
||||
it("updates ACP config options and keeps cwd local when using /acp set", async () => {
|
||||
mockBoundThreadSession();
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
|
||||
import {
|
||||
handleAcpDoctorAction,
|
||||
handleAcpInstallAction,
|
||||
@ -56,6 +57,21 @@ const ACP_ACTION_HANDLERS: Record<Exclude<AcpAction, "help">, AcpActionHandler>
|
||||
sessions: async (params, tokens) => handleAcpSessionsAction(params, tokens),
|
||||
};
|
||||
|
||||
const ACP_MUTATING_ACTIONS = new Set<AcpAction>([
|
||||
"spawn",
|
||||
"cancel",
|
||||
"steer",
|
||||
"close",
|
||||
"status",
|
||||
"set-mode",
|
||||
"set",
|
||||
"cwd",
|
||||
"permissions",
|
||||
"timeout",
|
||||
"model",
|
||||
"reset-options",
|
||||
]);
|
||||
|
||||
export const handleAcpCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
@ -78,6 +94,17 @@ export const handleAcpCommand: CommandHandler = async (params, allowTextCommands
|
||||
return stopWithText(resolveAcpHelpText());
|
||||
}
|
||||
|
||||
if (ACP_MUTATING_ACTIONS.has(action)) {
|
||||
const scopeBlock = requireGatewayClientScopeForInternalChannel(params, {
|
||||
label: "/acp",
|
||||
allowedScopes: ["operator.admin"],
|
||||
missingText: "This /acp action requires operator.admin on the internal channel.",
|
||||
});
|
||||
if (scopeBlock) {
|
||||
return scopeBlock;
|
||||
}
|
||||
}
|
||||
|
||||
const handler = ACP_ACTION_HANDLERS[action];
|
||||
return handler ? await handler(params, tokens) : stopWithText(resolveAcpHelpText());
|
||||
};
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
__testing as feishuThreadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
getSessionBindingService,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
|
||||
import {
|
||||
isAcpCommandDiscordChannel,
|
||||
resolveAcpCommandBindingContext,
|
||||
resolveAcpCommandConversationId,
|
||||
resolveAcpCommandParentConversationId,
|
||||
} from "./context.js";
|
||||
|
||||
const baseCfg = {
|
||||
@ -12,6 +21,11 @@ const baseCfg = {
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
describe("commands-acp context", () => {
|
||||
beforeEach(() => {
|
||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
});
|
||||
|
||||
it("resolves channel/account/thread context from originating fields", () => {
|
||||
const params = buildCommandTestParams("/acp sessions", baseCfg, {
|
||||
Provider: "discord",
|
||||
@ -126,4 +140,166 @@ describe("commands-acp context", () => {
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
});
|
||||
|
||||
it("builds Feishu topic conversation ids from chat target + root message id", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "chat:oc_group_chat",
|
||||
MessageThreadId: "om_topic_root",
|
||||
SenderId: "ou_topic_user",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
threadId: "om_topic_root",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("oc_group_chat:topic:om_topic_root");
|
||||
});
|
||||
|
||||
it("builds sender-scoped Feishu topic conversation ids when current session is sender-scoped", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "chat:oc_group_chat",
|
||||
MessageThreadId: "om_topic_root",
|
||||
SenderId: "ou_topic_user",
|
||||
AccountId: "work",
|
||||
SessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
});
|
||||
params.sessionKey =
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user";
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
threadId: "om_topic_root",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe(
|
||||
"oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves sender-scoped Feishu topic ids after ACP route takeover via ParentSessionKey", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "chat:oc_group_chat",
|
||||
MessageThreadId: "om_topic_root",
|
||||
SenderId: "ou_topic_user",
|
||||
AccountId: "work",
|
||||
ParentSessionKey:
|
||||
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
});
|
||||
params.sessionKey = "agent:codex:acp:binding:feishu:work:abc123";
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
threadId: "om_topic_root",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves sender-scoped Feishu topic ids after ACP takeover from the live binding record", async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "work" });
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:work:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "chat:oc_group_chat",
|
||||
MessageThreadId: "om_topic_root",
|
||||
SenderId: "ou_topic_user",
|
||||
AccountId: "work",
|
||||
});
|
||||
params.sessionKey = "agent:codex:acp:binding:feishu:work:abc123";
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
threadId: "om_topic_root",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
||||
parentConversationId: "oc_group_chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Feishu DM conversation ids from user targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "user:ou_sender_1",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "ou_sender_1",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("ou_sender_1");
|
||||
});
|
||||
|
||||
it("resolves Feishu DM conversation ids from user_id fallback targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "user:user_123",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "user_123",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("user_123");
|
||||
});
|
||||
|
||||
it("does not infer a Feishu DM parent conversation id during fallback binding lookup", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "feishu",
|
||||
Surface: "feishu",
|
||||
OriginatingChannel: "feishu",
|
||||
OriginatingTo: "user:ou_sender_1",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandParentConversationId(params)).toBeUndefined();
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
threadId: undefined,
|
||||
conversationId: "ou_sender_1",
|
||||
parentConversationId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js";
|
||||
import {
|
||||
buildTelegramTopicConversationId,
|
||||
normalizeConversationText,
|
||||
@ -5,10 +6,107 @@ import {
|
||||
} from "../../../acp/conversation-id.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
function parseFeishuTargetId(raw: unknown): string | undefined {
|
||||
const target = normalizeConversationText(raw);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
|
||||
if (!withoutProvider) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = withoutProvider.toLowerCase();
|
||||
for (const prefix of ["chat:", "group:", "channel:", "user:", "dm:", "open_id:"]) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
return normalizeConversationText(withoutProvider.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
return withoutProvider;
|
||||
}
|
||||
|
||||
function parseFeishuDirectConversationId(raw: unknown): string | undefined {
|
||||
const target = normalizeConversationText(raw);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutProvider = target.replace(/^(feishu|lark):/i, "").trim();
|
||||
if (!withoutProvider) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = withoutProvider.toLowerCase();
|
||||
for (const prefix of ["user:", "dm:", "open_id:"]) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
return normalizeConversationText(withoutProvider.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
const id = parseFeishuTargetId(target);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
if (id.startsWith("ou_") || id.startsWith("on_")) {
|
||||
return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveFeishuSenderScopedConversationId(params: {
|
||||
accountId: string;
|
||||
parentConversationId?: string;
|
||||
threadId?: string;
|
||||
senderId?: string;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
}): string | undefined {
|
||||
const parentConversationId = normalizeConversationText(params.parentConversationId);
|
||||
const threadId = normalizeConversationText(params.threadId);
|
||||
const senderId = normalizeConversationText(params.senderId);
|
||||
const expectedScopePrefix = `feishu:group:${parentConversationId?.toLowerCase()}:topic:${threadId?.toLowerCase()}:sender:`;
|
||||
const isSenderScopedSession = [params.sessionKey, params.parentSessionKey].some((candidate) => {
|
||||
const scopedRest = parseAgentSessionKey(candidate)?.rest?.trim().toLowerCase() ?? "";
|
||||
return Boolean(scopedRest && expectedScopePrefix && scopedRest.startsWith(expectedScopePrefix));
|
||||
});
|
||||
if (!parentConversationId || !threadId || !senderId) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isSenderScopedSession && params.sessionKey?.trim()) {
|
||||
const boundConversation = getSessionBindingService()
|
||||
.listBySession(params.sessionKey)
|
||||
.find((binding) => {
|
||||
if (
|
||||
binding.conversation.channel !== "feishu" ||
|
||||
binding.conversation.accountId !== params.accountId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
binding.conversation.conversationId ===
|
||||
buildFeishuConversationId({
|
||||
chatId: parentConversationId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: threadId,
|
||||
senderOpenId: senderId,
|
||||
})
|
||||
);
|
||||
});
|
||||
if (boundConversation) {
|
||||
return boundConversation.conversation.conversationId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return buildFeishuConversationId({
|
||||
chatId: parentConversationId,
|
||||
scope: "group_topic_sender",
|
||||
topicId: threadId,
|
||||
senderOpenId: senderId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
|
||||
const raw =
|
||||
params.ctx.OriginatingChannel ??
|
||||
@ -58,6 +156,33 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
|
||||
);
|
||||
}
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const threadId = resolveAcpCommandThreadId(params);
|
||||
const parentConversationId = resolveAcpCommandParentConversationId(params);
|
||||
if (threadId && parentConversationId) {
|
||||
const senderScopedConversationId = resolveFeishuSenderScopedConversationId({
|
||||
accountId: resolveAcpCommandAccountId(params),
|
||||
parentConversationId,
|
||||
threadId,
|
||||
senderId: params.command.senderId ?? params.ctx.SenderId,
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.ctx.ParentSessionKey,
|
||||
});
|
||||
return (
|
||||
senderScopedConversationId ??
|
||||
buildFeishuConversationId({
|
||||
chatId: parentConversationId,
|
||||
scope: "group_topic",
|
||||
topicId: threadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
return (
|
||||
parseFeishuDirectConversationId(params.ctx.OriginatingTo) ??
|
||||
parseFeishuDirectConversationId(params.command.to) ??
|
||||
parseFeishuDirectConversationId(params.ctx.To)
|
||||
);
|
||||
}
|
||||
return resolveConversationIdFromTargets({
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
|
||||
@ -83,6 +208,17 @@ export function resolveAcpCommandParentConversationId(
|
||||
parseTelegramChatIdFromTarget(params.ctx.To)
|
||||
);
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const threadId = resolveAcpCommandThreadId(params);
|
||||
if (!threadId) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
parseFeishuTargetId(params.ctx.OriginatingTo) ??
|
||||
parseFeishuTargetId(params.command.to) ??
|
||||
parseFeishuTargetId(params.ctx.To)
|
||||
);
|
||||
}
|
||||
if (channel === DISCORD_THREAD_BINDING_CHANNEL) {
|
||||
const threadId = resolveAcpCommandThreadId(params);
|
||||
if (!threadId) {
|
||||
|
||||
@ -125,7 +125,7 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
|
||||
const currentThreadId = bindingContext.threadId ?? "";
|
||||
const currentConversationId = bindingContext.conversationId?.trim() || "";
|
||||
const requiresThreadIdForHere = channel !== "telegram";
|
||||
const requiresThreadIdForHere = channel !== "telegram" && channel !== "feishu";
|
||||
if (
|
||||
threadMode === "here" &&
|
||||
((requiresThreadIdForHere && !currentThreadId) ||
|
||||
@ -137,7 +137,12 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const placement = channel === "telegram" ? "current" : currentThreadId ? "current" : "child";
|
||||
const placement =
|
||||
channel === "telegram" || channel === "feishu"
|
||||
? "current"
|
||||
: currentThreadId
|
||||
? "current"
|
||||
: "child";
|
||||
if (!capabilities.placements.includes(placement)) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@ -2,6 +2,8 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { compileConfigRegexes, type ConfigRegexRejectReason } from "../../security/config-regex.js";
|
||||
import { escapeRegExp } from "../../utils.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
@ -21,8 +23,12 @@ function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
|
||||
}
|
||||
|
||||
const BACKSPACE_CHAR = "\u0008";
|
||||
const mentionRegexCompileCache = new Map<string, RegExp[]>();
|
||||
const mentionMatchRegexCompileCache = new Map<string, RegExp[]>();
|
||||
const mentionStripRegexCompileCache = new Map<string, RegExp[]>();
|
||||
const MAX_MENTION_REGEX_COMPILE_CACHE_KEYS = 512;
|
||||
const mentionPatternWarningCache = new Set<string>();
|
||||
const MAX_MENTION_PATTERN_WARNING_KEYS = 512;
|
||||
const log = createSubsystemLogger("mentions");
|
||||
|
||||
export const CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
|
||||
|
||||
@ -37,6 +43,64 @@ function normalizeMentionPatterns(patterns: string[]): string[] {
|
||||
return patterns.map(normalizeMentionPattern);
|
||||
}
|
||||
|
||||
function warnRejectedMentionPattern(
|
||||
pattern: string,
|
||||
flags: string,
|
||||
reason: ConfigRegexRejectReason,
|
||||
) {
|
||||
const key = `${flags}::${reason}::${pattern}`;
|
||||
if (mentionPatternWarningCache.has(key)) {
|
||||
return;
|
||||
}
|
||||
mentionPatternWarningCache.add(key);
|
||||
if (mentionPatternWarningCache.size > MAX_MENTION_PATTERN_WARNING_KEYS) {
|
||||
mentionPatternWarningCache.clear();
|
||||
mentionPatternWarningCache.add(key);
|
||||
}
|
||||
log.warn("Ignoring unsupported group mention pattern", {
|
||||
pattern,
|
||||
flags,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
function cacheMentionRegexes(
|
||||
cache: Map<string, RegExp[]>,
|
||||
cacheKey: string,
|
||||
regexes: RegExp[],
|
||||
): RegExp[] {
|
||||
cache.set(cacheKey, regexes);
|
||||
if (cache.size > MAX_MENTION_REGEX_COMPILE_CACHE_KEYS) {
|
||||
cache.clear();
|
||||
cache.set(cacheKey, regexes);
|
||||
}
|
||||
return [...regexes];
|
||||
}
|
||||
|
||||
function compileMentionPatternsCached(params: {
|
||||
patterns: string[];
|
||||
flags: string;
|
||||
cache: Map<string, RegExp[]>;
|
||||
warnRejected: boolean;
|
||||
}): RegExp[] {
|
||||
if (params.patterns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const cacheKey = `${params.flags}\u001e${params.patterns.join("\u001f")}`;
|
||||
const cached = params.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return [...cached];
|
||||
}
|
||||
|
||||
const compiled = compileConfigRegexes(params.patterns, params.flags);
|
||||
if (params.warnRejected) {
|
||||
for (const rejected of compiled.rejected) {
|
||||
warnRejectedMentionPattern(rejected.pattern, rejected.flags, rejected.reason);
|
||||
}
|
||||
}
|
||||
return cacheMentionRegexes(params.cache, cacheKey, compiled.regexes);
|
||||
}
|
||||
|
||||
function resolveMentionPatterns(cfg: OpenClawConfig | undefined, agentId?: string): string[] {
|
||||
if (!cfg) {
|
||||
return [];
|
||||
@ -56,29 +120,12 @@ function resolveMentionPatterns(cfg: OpenClawConfig | undefined, agentId?: strin
|
||||
|
||||
export function buildMentionRegexes(cfg: OpenClawConfig | undefined, agentId?: string): RegExp[] {
|
||||
const patterns = normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId));
|
||||
if (patterns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const cacheKey = patterns.join("\u001f");
|
||||
const cached = mentionRegexCompileCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return [...cached];
|
||||
}
|
||||
const compiled = patterns
|
||||
.map((pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern, "i");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((value): value is RegExp => Boolean(value));
|
||||
mentionRegexCompileCache.set(cacheKey, compiled);
|
||||
if (mentionRegexCompileCache.size > MAX_MENTION_REGEX_COMPILE_CACHE_KEYS) {
|
||||
mentionRegexCompileCache.clear();
|
||||
mentionRegexCompileCache.set(cacheKey, compiled);
|
||||
}
|
||||
return [...compiled];
|
||||
return compileMentionPatternsCached({
|
||||
patterns,
|
||||
flags: "i",
|
||||
cache: mentionMatchRegexCompileCache,
|
||||
warnRejected: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeMentionText(text: string): string {
|
||||
@ -153,17 +200,24 @@ export function stripMentions(
|
||||
let result = text;
|
||||
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
|
||||
const providerMentions = providerId ? getChannelDock(providerId)?.mentions : undefined;
|
||||
const patterns = normalizeMentionPatterns([
|
||||
...resolveMentionPatterns(cfg, agentId),
|
||||
...(providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? []),
|
||||
]);
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
result = result.replace(re, " ");
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
const configRegexes = compileMentionPatternsCached({
|
||||
patterns: normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId)),
|
||||
flags: "gi",
|
||||
cache: mentionStripRegexCompileCache,
|
||||
warnRejected: true,
|
||||
});
|
||||
const providerRegexes =
|
||||
providerMentions?.stripRegexes?.({ ctx, cfg, agentId }) ??
|
||||
compileMentionPatternsCached({
|
||||
patterns: normalizeMentionPatterns(
|
||||
providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? [],
|
||||
),
|
||||
flags: "gi",
|
||||
cache: mentionStripRegexCompileCache,
|
||||
warnRejected: false,
|
||||
});
|
||||
for (const re of [...configRegexes, ...providerRegexes]) {
|
||||
result = result.replace(re, " ");
|
||||
}
|
||||
if (providerMentions?.stripMentions) {
|
||||
result = providerMentions.stripMentions({
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import WebSocket from "ws";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
|
||||
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
|
||||
@ -22,6 +24,40 @@ export function isWebSocketUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertCdpEndpointAllowed(
|
||||
cdpUrl: string,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<void> {
|
||||
if (!ssrfPolicy) {
|
||||
return;
|
||||
}
|
||||
const parsed = new URL(cdpUrl);
|
||||
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
|
||||
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
||||
}
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
|
||||
if (typeof cdpUrl !== "string") {
|
||||
return cdpUrl;
|
||||
}
|
||||
const trimmed = cdpUrl.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
|
||||
} catch {
|
||||
return redactSensitiveText(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
type CdpResponse = {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
|
||||
@ -302,6 +302,24 @@ describe("browser chrome helpers", () => {
|
||||
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await expect(
|
||||
isChromeReachable("http://127.0.0.1:12345", 50, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
isChromeReachable("ws://127.0.0.1:19999", 50, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
|
||||
await withMockChromeCdpServer({
|
||||
wsPath: "/devtools/browser/health",
|
||||
|
||||
@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@ -17,7 +18,13 @@ import {
|
||||
CHROME_STOP_TIMEOUT_MS,
|
||||
CHROME_WS_READY_TIMEOUT_MS,
|
||||
} from "./cdp-timeouts.js";
|
||||
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
|
||||
import {
|
||||
appendCdpPath,
|
||||
assertCdpEndpointAllowed,
|
||||
fetchCdpChecked,
|
||||
isWebSocketUrl,
|
||||
openCdpWebSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
type BrowserExecutable,
|
||||
@ -96,13 +103,19 @@ async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean
|
||||
export async function isChromeReachable(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<boolean> {
|
||||
if (isWebSocketUrl(cdpUrl)) {
|
||||
// Direct WebSocket endpoint — probe via WS handshake.
|
||||
return await canOpenWebSocket(cdpUrl, timeoutMs);
|
||||
try {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
if (isWebSocketUrl(cdpUrl)) {
|
||||
// Direct WebSocket endpoint — probe via WS handshake.
|
||||
return await canOpenWebSocket(cdpUrl, timeoutMs);
|
||||
}
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
|
||||
return Boolean(version);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||
return Boolean(version);
|
||||
}
|
||||
|
||||
type ChromeVersion = {
|
||||
@ -114,10 +127,12 @@ type ChromeVersion = {
|
||||
async function fetchChromeVersion(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<ChromeVersion | null> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||
try {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
||||
const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal });
|
||||
const data = (await res.json()) as ChromeVersion;
|
||||
@ -135,12 +150,14 @@ async function fetchChromeVersion(
|
||||
export async function getChromeWebSocketUrl(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<string | null> {
|
||||
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
|
||||
if (isWebSocketUrl(cdpUrl)) {
|
||||
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
|
||||
return cdpUrl;
|
||||
}
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
|
||||
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
|
||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
if (!wsUrl) {
|
||||
return null;
|
||||
@ -227,8 +244,9 @@ export async function isChromeCdpReady(
|
||||
cdpUrl: string,
|
||||
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
|
||||
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<boolean> {
|
||||
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
|
||||
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null);
|
||||
if (!wsUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -71,7 +71,12 @@ export function createProfileAvailability({
|
||||
return true;
|
||||
}
|
||||
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeCdpReady(profile.cdpUrl, httpTimeoutMs, wsTimeoutMs);
|
||||
return await isChromeCdpReady(
|
||||
profile.cdpUrl,
|
||||
httpTimeoutMs,
|
||||
wsTimeoutMs,
|
||||
state().resolved.ssrfPolicy,
|
||||
);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs?: number) => {
|
||||
@ -79,7 +84,7 @@ export function createProfileAvailability({
|
||||
return await isReachable(timeoutMs);
|
||||
}
|
||||
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
|
||||
};
|
||||
|
||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||
|
||||
@ -187,7 +187,11 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
} else {
|
||||
// Check if something is listening on the port
|
||||
try {
|
||||
const reachable = await isChromeReachable(profile.cdpUrl, 200);
|
||||
const reachable = await isChromeReachable(
|
||||
profile.cdpUrl,
|
||||
200,
|
||||
current.resolved.ssrfPolicy,
|
||||
);
|
||||
if (reachable) {
|
||||
running = true;
|
||||
const tabs = await profileCtx.listTabs().catch(() => []);
|
||||
|
||||
@ -58,7 +58,7 @@ import type {
|
||||
} from "./plugins/types.js";
|
||||
import {
|
||||
resolveWhatsAppGroupIntroHint,
|
||||
resolveWhatsAppMentionStripPatterns,
|
||||
resolveWhatsAppMentionStripRegexes,
|
||||
} from "./plugins/whatsapp-shared.js";
|
||||
import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
|
||||
|
||||
@ -303,7 +303,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx),
|
||||
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
@ -346,7 +346,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
stripRegexes: () => [/<@!?\d+>/g],
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
|
||||
@ -484,7 +484,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@[^>]+>"],
|
||||
stripRegexes: () => [/<@[^>]+>/g],
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
|
||||
@ -209,6 +209,11 @@ export type ChannelSecurityContext<ResolvedAccount = unknown> = {
|
||||
};
|
||||
|
||||
export type ChannelMentionAdapter = {
|
||||
stripRegexes?: (params: {
|
||||
ctx: MsgContext;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
agentId?: string;
|
||||
}) => RegExp[];
|
||||
stripPatterns?: (params: {
|
||||
ctx: MsgContext;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
|
||||
@ -11,13 +11,13 @@ export function resolveWhatsAppGroupIntroHint(): string {
|
||||
return WHATSAPP_GROUP_INTRO_HINT;
|
||||
}
|
||||
|
||||
export function resolveWhatsAppMentionStripPatterns(ctx: { To?: string | null }): string[] {
|
||||
export function resolveWhatsAppMentionStripRegexes(ctx: { To?: string | null }): RegExp[] {
|
||||
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
if (!selfE164) {
|
||||
return [];
|
||||
}
|
||||
const escaped = escapeRegExp(selfE164);
|
||||
return [escaped, `@${escaped}`];
|
||||
return [new RegExp(escaped, "g"), new RegExp(`@${escaped}`, "g")];
|
||||
}
|
||||
|
||||
type WhatsAppChunker = NonNullable<ChannelOutboundAdapter["chunker"]>;
|
||||
|
||||
@ -148,4 +148,42 @@ describe("browser manage output", () => {
|
||||
expect(output).toContain("transport: chrome-mcp");
|
||||
expect(output).not.toContain("port: 0");
|
||||
});
|
||||
|
||||
it("redacts sensitive remote cdpUrl details in status output", async () => {
|
||||
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
|
||||
req.path === "/"
|
||||
? {
|
||||
enabled: true,
|
||||
profile: "remote",
|
||||
driver: "openclaw",
|
||||
transport: "cdp",
|
||||
running: true,
|
||||
cdpReady: true,
|
||||
cdpHttp: true,
|
||||
pid: null,
|
||||
cdpPort: 9222,
|
||||
cdpUrl:
|
||||
"https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890",
|
||||
chosenBrowser: null,
|
||||
userDataDir: null,
|
||||
color: "#00AA00",
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
executablePath: null,
|
||||
attachOnly: true,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["browser", "--browser-profile", "remote", "status"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string;
|
||||
expect(output).toContain("cdpUrl: https://example.com/chrome?token=supers…7890");
|
||||
expect(output).not.toContain("alice");
|
||||
expect(output).not.toContain("supersecretpasswordvalue1234");
|
||||
expect(output).not.toContain("supersecrettokenvalue1234567890");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import type {
|
||||
BrowserTransport,
|
||||
BrowserCreateProfileResult,
|
||||
@ -152,7 +153,7 @@ export function registerBrowserManageCommands(
|
||||
...(!usesChromeMcpTransport(status)
|
||||
? [
|
||||
`cdpPort: ${status.cdpPort ?? "(unset)"}`,
|
||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||
`cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`,
|
||||
]
|
||||
: []),
|
||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||
|
||||
@ -1,13 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
channelsAddCommand,
|
||||
channelsCapabilitiesCommand,
|
||||
channelsListCommand,
|
||||
channelsLogsCommand,
|
||||
channelsRemoveCommand,
|
||||
channelsResolveCommand,
|
||||
channelsStatusCommand,
|
||||
} from "../commands/channels.js";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
@ -96,6 +87,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsListCommand } = await import("../commands/channels.js");
|
||||
await channelsListCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -108,6 +100,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsStatusCommand } = await import("../commands/channels.js");
|
||||
await channelsStatusCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -122,6 +115,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsCapabilitiesCommand } = await import("../commands/channels.js");
|
||||
await channelsCapabilitiesCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -136,6 +130,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (entries, opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsResolveCommand } = await import("../commands/channels.js");
|
||||
await channelsResolveCommand(
|
||||
{
|
||||
channel: opts.channel as string | undefined,
|
||||
@ -157,6 +152,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsLogsCommand } = await import("../commands/channels.js");
|
||||
await channelsLogsCommand(opts, defaultRuntime);
|
||||
});
|
||||
});
|
||||
@ -200,6 +196,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--use-env", "Use env token (default account only)", false)
|
||||
.action(async (opts, command) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsAddCommand } = await import("../commands/channels.js");
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
|
||||
await channelsAddCommand(opts, defaultRuntime, { hasFlags });
|
||||
});
|
||||
@ -213,6 +210,7 @@ export function registerChannelsCli(program: Command) {
|
||||
.option("--delete", "Delete config entries (no prompt)", false)
|
||||
.action(async (opts, command) => {
|
||||
await runChannelsCommand(async () => {
|
||||
const { channelsRemoveCommand } = await import("../commands/channels.js");
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
|
||||
await channelsRemoveCommand(opts, defaultRuntime, { hasFlags });
|
||||
});
|
||||
|
||||
@ -235,6 +235,10 @@ function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescript
|
||||
return names;
|
||||
}
|
||||
|
||||
export function getCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {
|
||||
return coreEntries.flatMap((entry) => entry.commands);
|
||||
}
|
||||
|
||||
export function getCoreCliCommandNames(): string[] {
|
||||
return collectCoreCliCommandNames();
|
||||
}
|
||||
|
||||
29
src/cli/program/root-help.ts
Normal file
29
src/cli/program/root-help.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Command } from "commander";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { getCoreCliCommandDescriptors } from "./command-registry.js";
|
||||
import { configureProgramHelp } from "./help.js";
|
||||
import { getSubCliEntries } from "./register.subclis.js";
|
||||
|
||||
function buildRootHelpProgram(): Command {
|
||||
const program = new Command();
|
||||
configureProgramHelp(program, {
|
||||
programVersion: VERSION,
|
||||
channelOptions: [],
|
||||
messageChannelOptions: "",
|
||||
agentChannelOptions: "",
|
||||
});
|
||||
|
||||
for (const command of getCoreCliCommandDescriptors()) {
|
||||
program.command(command.name).description(command.description);
|
||||
}
|
||||
for (const command of getSubCliEntries()) {
|
||||
program.command(command.name).description(command.description);
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export function outputRootHelp(): void {
|
||||
const program = buildRootHelpProgram();
|
||||
program.outputHelp();
|
||||
}
|
||||
@ -32,7 +32,7 @@ describe("program routes", () => {
|
||||
await expect(route?.run(argv)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
it("matches status route and always loads plugins for security parity", () => {
|
||||
it("matches status route and always preloads plugins", () => {
|
||||
const route = expectRoute(["status"]);
|
||||
expect(route?.loadPlugins).toBe(true);
|
||||
});
|
||||
|
||||
@ -7,6 +7,8 @@ const normalizeEnvMock = vi.hoisted(() => vi.fn());
|
||||
const ensurePathMock = vi.hoisted(() => vi.fn());
|
||||
const assertRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const outputRootHelpMock = vi.hoisted(() => vi.fn());
|
||||
const buildProgramMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./route.js", () => ({
|
||||
tryRouteCli: tryRouteCliMock,
|
||||
@ -32,6 +34,14 @@ vi.mock("../memory/search-manager.js", () => ({
|
||||
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
|
||||
}));
|
||||
|
||||
vi.mock("./program/root-help.js", () => ({
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
}));
|
||||
|
||||
vi.mock("./program.js", () => ({
|
||||
buildProgram: buildProgramMock,
|
||||
}));
|
||||
|
||||
const { runCli } = await import("./run-main.js");
|
||||
|
||||
describe("runCli exit behavior", () => {
|
||||
@ -52,4 +62,19 @@ describe("runCli exit behavior", () => {
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders root help without building the full program", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||
throw new Error(`unexpected process.exit(${String(code)})`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await runCli(["node", "openclaw", "--help"]);
|
||||
|
||||
expect(tryRouteCliMock).not.toHaveBeenCalled();
|
||||
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildProgramMock).not.toHaveBeenCalled();
|
||||
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
shouldEnsureCliPath,
|
||||
shouldRegisterPrimarySubcommand,
|
||||
shouldSkipPluginCommandRegistration,
|
||||
shouldUseRootHelpFastPath,
|
||||
} from "./run-main.js";
|
||||
|
||||
describe("rewriteUpdateFlagArgv", () => {
|
||||
@ -126,3 +127,12 @@ describe("shouldEnsureCliPath", () => {
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "acp", "-v"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseRootHelpFastPath", () => {
|
||||
it("uses the fast path for root help only", () => {
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--profile", "work", "-h"])).toBe(true);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false);
|
||||
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help", "status"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,12 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { getCommandPathWithRootOptions, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import {
|
||||
getCommandPathWithRootOptions,
|
||||
getPrimaryCommand,
|
||||
hasHelpOrVersion,
|
||||
isRootHelpInvocation,
|
||||
} from "./argv.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
import { normalizeWindowsArgv } from "./windows-argv.js";
|
||||
@ -71,6 +76,10 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldUseRootHelpFastPath(argv: string[]): boolean {
|
||||
return isRootHelpInvocation(argv);
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[] = process.argv) {
|
||||
let normalizedArgv = normalizeWindowsArgv(argv);
|
||||
const parsedProfile = parseCliProfileArgs(normalizedArgv);
|
||||
@ -92,6 +101,12 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
assertSupportedRuntime();
|
||||
|
||||
try {
|
||||
if (shouldUseRootHelpFastPath(normalizedArgv)) {
|
||||
const { outputRootHelp } = await import("./program/root-help.js");
|
||||
outputRootHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryRouteCli(normalizedArgv)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js";
|
||||
import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
|
||||
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
|
||||
@ -11,13 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||
import { buildAgentSummaries } from "../agents.config.js";
|
||||
import { setupChannels } from "../onboard-channels.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "../onboarding/plugin-install.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
|
||||
@ -56,6 +48,10 @@ export async function channelsAddCommand(
|
||||
|
||||
const useWizard = shouldUseWizard(params);
|
||||
if (useWizard) {
|
||||
const [{ buildAgentSummaries }, { setupChannels }] = await Promise.all([
|
||||
import("../agents.config.js"),
|
||||
import("../onboard-channels.js"),
|
||||
]);
|
||||
const prompter = createClackPrompter();
|
||||
let selection: ChannelChoice[] = [];
|
||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
@ -176,6 +172,8 @@ export async function channelsAddCommand(
|
||||
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
|
||||
|
||||
if (!channel && catalogEntry) {
|
||||
const { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry } =
|
||||
await import("../onboarding/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
@ -269,10 +267,20 @@ export async function channelsAddCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTelegramToken =
|
||||
channel === "telegram"
|
||||
? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim()
|
||||
: "";
|
||||
let previousTelegramToken = "";
|
||||
let resolveTelegramAccount:
|
||||
| ((
|
||||
params: Parameters<
|
||||
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
|
||||
>[0],
|
||||
) => ReturnType<
|
||||
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
|
||||
>)
|
||||
| undefined;
|
||||
if (channel === "telegram") {
|
||||
({ resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"));
|
||||
previousTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
|
||||
}
|
||||
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
|
||||
@ -288,7 +296,9 @@ export async function channelsAddCommand(
|
||||
input,
|
||||
});
|
||||
|
||||
if (channel === "telegram") {
|
||||
if (channel === "telegram" && resolveTelegramAccount) {
|
||||
const { deleteTelegramUpdateOffset } =
|
||||
await import("../../../extensions/telegram/src/update-offset-store.js");
|
||||
const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
|
||||
if (previousTelegramToken !== nextTelegramToken) {
|
||||
// Clear stale polling offsets after Telegram token rotation.
|
||||
|
||||
@ -417,6 +417,12 @@ describe("statusCommand", () => {
|
||||
expect(payload.securityAudit.summary.warn).toBe(1);
|
||||
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
||||
expect(payload.nodeService.label).toBe("LaunchAgent");
|
||||
expect(mocks.runSecurityAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces unknown usage when totalTokens is missing", async () => {
|
||||
@ -505,8 +511,8 @@ describe("statusCommand", () => {
|
||||
|
||||
await statusCommand({ json: true }, runtime as never);
|
||||
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
|
||||
expect(payload.gateway.error).toContain("gateway.auth.token");
|
||||
expect(payload.gateway.error).toContain("SecretRef");
|
||||
expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull();
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces channel runtime errors from the gateway", async () => {
|
||||
|
||||
@ -144,4 +144,112 @@ describe("ACP binding cutover schema", () => {
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts canonical Feishu ACP DM and topic peer IDs", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "ou_user_123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "user_123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root:sender:ou_user_123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-canonical Feishu ACP peer IDs", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat:sender:ou_user_123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Feishu ACP DM peer IDs keyed by union id", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "on_union_user_123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Feishu ACP topic peer IDs with non-canonical sender ids", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root:sender:user_123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects bare Feishu group chat ACP peer IDs", () => {
|
||||
const parsed = OpenClawSchema.safeParse({
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "oc_group_chat" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1346,7 +1346,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"messages.groupChat":
|
||||
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
|
||||
"messages.groupChat.mentionPatterns":
|
||||
"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.",
|
||||
"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.",
|
||||
"messages.groupChat.historyLimit":
|
||||
"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.",
|
||||
"messages.queue":
|
||||
|
||||
@ -75,6 +75,8 @@ export type GoogleChatAccountConfig = {
|
||||
audienceType?: "app-url" | "project-number";
|
||||
/** Audience value (app URL or project number). */
|
||||
audience?: string;
|
||||
/** Exact add-on principal to accept when app-url delivery uses add-on tokens. */
|
||||
appPrincipal?: string;
|
||||
/** Google Chat webhook path (default: /googlechat). */
|
||||
webhookPath?: string;
|
||||
/** Google Chat webhook URL (used to derive the path). */
|
||||
|
||||
@ -71,11 +71,12 @@ const AcpBindingSchema = z
|
||||
return;
|
||||
}
|
||||
const channel = value.match.channel.trim().toLowerCase();
|
||||
if (channel !== "discord" && channel !== "telegram") {
|
||||
if (channel !== "discord" && channel !== "telegram" && channel !== "feishu") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match", "channel"],
|
||||
message: 'ACP bindings currently support only "discord" and "telegram" channels.',
|
||||
message:
|
||||
'ACP bindings currently support only "discord", "telegram", and "feishu" channels.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -87,6 +88,24 @@ const AcpBindingSchema = z
|
||||
"Telegram ACP bindings require canonical topic IDs in the form -1001234567890:topic:42.",
|
||||
});
|
||||
}
|
||||
if (channel === "feishu") {
|
||||
const peerKind = value.match.peer?.kind;
|
||||
const isDirectId =
|
||||
(peerKind === "direct" || peerKind === "dm") &&
|
||||
/^[^:]+$/.test(peerId) &&
|
||||
!peerId.startsWith("oc_") &&
|
||||
!peerId.startsWith("on_");
|
||||
const isTopicId =
|
||||
peerKind === "group" && /^oc_[^:]+:topic:[^:]+(?::sender:ou_[^:]+)?$/.test(peerId);
|
||||
if (!isDirectId && !isTopicId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match", "peer", "id"],
|
||||
message:
|
||||
"Feishu ACP bindings require direct peer IDs for DMs or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const BindingsSchema = z.array(z.union([RouteBindingSchema, AcpBindingSchema])).optional();
|
||||
|
||||
@ -767,6 +767,7 @@ export const GoogleChatAccountSchema = z
|
||||
serviceAccountFile: z.string().optional(),
|
||||
audienceType: z.enum(["app-url", "project-number"]).optional(),
|
||||
audience: z.string().optional(),
|
||||
appPrincipal: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
botUser: z.string().optional(),
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
listDevicePairing,
|
||||
removePairedDevice,
|
||||
type DeviceAuthToken,
|
||||
type RotateDeviceTokenDenyReason,
|
||||
rejectDevicePairing,
|
||||
revokeDeviceToken,
|
||||
rotateDeviceToken,
|
||||
@ -24,6 +25,8 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const DEVICE_TOKEN_ROTATION_DENIED_MESSAGE = "device token rotation denied";
|
||||
|
||||
function redactPairedDevice(
|
||||
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
|
||||
) {
|
||||
@ -53,6 +56,19 @@ function resolveMissingRequestedScope(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function logDeviceTokenRotationDenied(params: {
|
||||
log: { warn: (message: string) => void };
|
||||
deviceId: string;
|
||||
role: string;
|
||||
reason: RotateDeviceTokenDenyReason | "caller-missing-scope" | "unknown-device-or-role";
|
||||
scope?: string | null;
|
||||
}) {
|
||||
const suffix = params.scope ? ` scope=${params.scope}` : "";
|
||||
params.log.warn(
|
||||
`device token rotation denied device=${params.deviceId} role=${params.role} reason=${params.reason}${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const deviceHandlers: GatewayRequestHandlers = {
|
||||
"device.pair.list": async ({ params, respond }) => {
|
||||
if (!validateDevicePairListParams(params)) {
|
||||
@ -189,7 +205,17 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
const pairedDevice = await getPairedDevice(deviceId);
|
||||
if (!pairedDevice) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||
logDeviceTokenRotationDenied({
|
||||
log: context.logGateway,
|
||||
deviceId,
|
||||
role,
|
||||
reason: "unknown-device-or-role",
|
||||
});
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
@ -202,18 +228,36 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
||||
callerScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
logDeviceTokenRotationDenied({
|
||||
log: context.logGateway,
|
||||
deviceId,
|
||||
role,
|
||||
reason: "caller-missing-scope",
|
||||
scope: missingScope,
|
||||
});
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const entry = await rotateDeviceToken({ deviceId, role, scopes });
|
||||
if (!entry) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||
const rotated = await rotateDeviceToken({ deviceId, role, scopes });
|
||||
if (!rotated.ok) {
|
||||
logDeviceTokenRotationDenied({
|
||||
log: context.logGateway,
|
||||
deviceId,
|
||||
role,
|
||||
reason: rotated.reason,
|
||||
});
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const entry = rotated.entry;
|
||||
context.logGateway.info(
|
||||
`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`,
|
||||
);
|
||||
|
||||
@ -2,10 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../protocol/index.js";
|
||||
import { maybeWakeNodeWithApns, nodeHandlers } from "./nodes.js";
|
||||
|
||||
type MockNodeCommandPolicyParams = {
|
||||
command: string;
|
||||
declaredCommands?: string[];
|
||||
allowlist: Set<string>;
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveNodeCommandAllowlist: vi.fn(() => []),
|
||||
isNodeCommandAllowed: vi.fn(() => ({ ok: true })),
|
||||
resolveNodeCommandAllowlist: vi.fn<() => Set<string>>(() => new Set()),
|
||||
isNodeCommandAllowed: vi.fn<
|
||||
(params: MockNodeCommandPolicyParams) => { ok: true } | { ok: false; reason: string }
|
||||
>(() => ({ ok: true })),
|
||||
sanitizeNodeInvokeParamsForForwarding: vi.fn(({ rawParams }: { rawParams: unknown }) => ({
|
||||
ok: true,
|
||||
params: rawParams,
|
||||
@ -213,9 +221,10 @@ async function invokeNode(params: {
|
||||
return respond;
|
||||
}
|
||||
|
||||
function createNodeClient(nodeId: string) {
|
||||
function createNodeClient(nodeId: string, commands?: string[]) {
|
||||
return {
|
||||
connect: {
|
||||
...(commands ? { commands } : {}),
|
||||
role: "node" as const,
|
||||
client: {
|
||||
id: nodeId,
|
||||
@ -228,26 +237,26 @@ function createNodeClient(nodeId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function pullPending(nodeId: string) {
|
||||
async function pullPending(nodeId: string, commands?: string[]) {
|
||||
const respond = vi.fn();
|
||||
await nodeHandlers["node.pending.pull"]({
|
||||
params: {},
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
client: createNodeClient(nodeId) as never,
|
||||
client: createNodeClient(nodeId, commands) as never,
|
||||
req: { type: "req", id: "req-node-pending", method: "node.pending.pull" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond;
|
||||
}
|
||||
|
||||
async function ackPending(nodeId: string, ids: string[]) {
|
||||
async function ackPending(nodeId: string, ids: string[], commands?: string[]) {
|
||||
const respond = vi.fn();
|
||||
await nodeHandlers["node.pending.ack"]({
|
||||
params: { ids },
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
client: createNodeClient(nodeId) as never,
|
||||
client: createNodeClient(nodeId, commands) as never,
|
||||
req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
@ -259,7 +268,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
mocks.loadConfig.mockClear();
|
||||
mocks.loadConfig.mockReturnValue({});
|
||||
mocks.resolveNodeCommandAllowlist.mockClear();
|
||||
mocks.resolveNodeCommandAllowlist.mockReturnValue([]);
|
||||
mocks.resolveNodeCommandAllowlist.mockReturnValue(new Set());
|
||||
mocks.isNodeCommandAllowed.mockClear();
|
||||
mocks.isNodeCommandAllowed.mockReturnValue({ ok: true });
|
||||
mocks.sanitizeNodeInvokeParamsForForwarding.mockClear();
|
||||
@ -470,7 +479,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
expect(call?.[2]?.message).toBe("node command queued until iOS returns to foreground");
|
||||
expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled();
|
||||
|
||||
const pullRespond = await pullPending("ios-node-queued");
|
||||
const pullRespond = await pullPending("ios-node-queued", ["canvas.navigate"]);
|
||||
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(pullCall?.[0]).toBe(true);
|
||||
expect(pullCall?.[1]).toMatchObject({
|
||||
@ -483,7 +492,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const repeatedPullRespond = await pullPending("ios-node-queued");
|
||||
const repeatedPullRespond = await pullPending("ios-node-queued", ["canvas.navigate"]);
|
||||
const repeatedPullCall = repeatedPullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(repeatedPullCall?.[0]).toBe(true);
|
||||
expect(repeatedPullCall?.[1]).toMatchObject({
|
||||
@ -500,7 +509,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
?.actions?.[0]?.id;
|
||||
expect(queuedActionId).toBeTruthy();
|
||||
|
||||
const ackRespond = await ackPending("ios-node-queued", [queuedActionId!]);
|
||||
const ackRespond = await ackPending("ios-node-queued", [queuedActionId!], ["canvas.navigate"]);
|
||||
const ackCall = ackRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(ackCall?.[0]).toBe(true);
|
||||
expect(ackCall?.[1]).toMatchObject({
|
||||
@ -509,7 +518,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
remainingCount: 0,
|
||||
});
|
||||
|
||||
const emptyPullRespond = await pullPending("ios-node-queued");
|
||||
const emptyPullRespond = await pullPending("ios-node-queued", ["canvas.navigate"]);
|
||||
const emptyPullCall = emptyPullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(emptyPullCall?.[0]).toBe(true);
|
||||
expect(emptyPullCall?.[1]).toMatchObject({
|
||||
@ -518,6 +527,74 @@ describe("node.invoke APNs wake path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops queued actions that are no longer allowed at pull time", async () => {
|
||||
mocks.loadApnsRegistration.mockResolvedValue(null);
|
||||
const allowlistedCommands = new Set(["camera.snap", "canvas.navigate"]);
|
||||
mocks.resolveNodeCommandAllowlist.mockImplementation(() => new Set(allowlistedCommands));
|
||||
mocks.isNodeCommandAllowed.mockImplementation(
|
||||
({ command, declaredCommands, allowlist }: MockNodeCommandPolicyParams) => {
|
||||
if (!allowlist.has(command)) {
|
||||
return { ok: false, reason: "command not allowlisted" };
|
||||
}
|
||||
if (!declaredCommands?.includes(command)) {
|
||||
return { ok: false, reason: "command not declared by node" };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
const nodeRegistry = {
|
||||
get: vi.fn(() => ({
|
||||
nodeId: "ios-node-policy",
|
||||
commands: ["camera.snap", "canvas.navigate"],
|
||||
platform: "iOS 26.4.0",
|
||||
})),
|
||||
invoke: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await invokeNode({
|
||||
nodeRegistry,
|
||||
requestParams: {
|
||||
nodeId: "ios-node-policy",
|
||||
command: "camera.snap",
|
||||
params: { facing: "front" },
|
||||
idempotencyKey: "idem-policy",
|
||||
},
|
||||
});
|
||||
|
||||
const preChangePullRespond = await pullPending("ios-node-policy", [
|
||||
"camera.snap",
|
||||
"canvas.navigate",
|
||||
]);
|
||||
const preChangePullCall = preChangePullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(preChangePullCall?.[0]).toBe(true);
|
||||
expect(preChangePullCall?.[1]).toMatchObject({
|
||||
nodeId: "ios-node-policy",
|
||||
actions: [
|
||||
expect.objectContaining({
|
||||
command: "camera.snap",
|
||||
paramsJSON: JSON.stringify({ facing: "front" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
allowlistedCommands.delete("camera.snap");
|
||||
|
||||
const pullRespond = await pullPending("ios-node-policy", ["camera.snap", "canvas.navigate"]);
|
||||
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(pullCall?.[0]).toBe(true);
|
||||
expect(pullCall?.[1]).toMatchObject({
|
||||
nodeId: "ios-node-policy",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes queued foreground actions by idempotency key", async () => {
|
||||
mocks.loadApnsRegistration.mockResolvedValue(null);
|
||||
|
||||
@ -555,7 +632,7 @@ describe("node.invoke APNs wake path", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const pullRespond = await pullPending("ios-node-dedupe");
|
||||
const pullRespond = await pullPending("ios-node-dedupe", ["canvas.navigate"]);
|
||||
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(pullCall?.[0]).toBe(true);
|
||||
expect(pullCall?.[1]).toMatchObject({
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
||||
import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
validateNodeDescribeParams,
|
||||
@ -218,6 +219,38 @@ function listPendingNodeActions(nodeId: string): PendingNodeAction[] {
|
||||
return prunePendingNodeActions(nodeId, Date.now());
|
||||
}
|
||||
|
||||
function resolveAllowedPendingNodeActions(params: {
|
||||
nodeId: string;
|
||||
client: { connect?: ConnectParams | null } | null;
|
||||
}): PendingNodeAction[] {
|
||||
const pending = listPendingNodeActions(params.nodeId);
|
||||
if (pending.length === 0) {
|
||||
return pending;
|
||||
}
|
||||
const connect = params.client?.connect;
|
||||
const declaredCommands = Array.isArray(connect?.commands) ? connect.commands : [];
|
||||
const allowlist = resolveNodeCommandAllowlist(loadConfig(), {
|
||||
platform: connect?.client?.platform,
|
||||
deviceFamily: connect?.client?.deviceFamily,
|
||||
});
|
||||
const allowed = pending.filter((entry) => {
|
||||
const result = isNodeCommandAllowed({
|
||||
command: entry.command,
|
||||
declaredCommands,
|
||||
allowlist,
|
||||
});
|
||||
return result.ok;
|
||||
});
|
||||
if (allowed.length !== pending.length) {
|
||||
if (allowed.length === 0) {
|
||||
pendingNodeActionsById.delete(params.nodeId);
|
||||
} else {
|
||||
pendingNodeActionsById.set(params.nodeId, allowed);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
function ackPendingNodeActions(nodeId: string, ids: string[]): PendingNodeAction[] {
|
||||
if (ids.length === 0) {
|
||||
return listPendingNodeActions(nodeId);
|
||||
@ -805,7 +838,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = listPendingNodeActions(trimmedNodeId);
|
||||
const pending = resolveAllowedPendingNodeActions({ nodeId: trimmedNodeId, client });
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
|
||||
@ -174,7 +174,9 @@ describe("gateway auth compatibility baseline", () => {
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(rotated?.token).toBeTruthy();
|
||||
expect(rotated.ok).toBe(true);
|
||||
const rotatedToken = rotated.ok ? rotated.entry.token : "";
|
||||
expect(rotatedToken).toBeTruthy();
|
||||
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
@ -182,7 +184,7 @@ describe("gateway auth compatibility baseline", () => {
|
||||
skipDefaultAuth: true,
|
||||
client: { ...BACKEND_GATEWAY_CLIENT },
|
||||
deviceIdentityPath: identityPath,
|
||||
deviceToken: String(rotated?.token ?? ""),
|
||||
deviceToken: rotatedToken,
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
@ -87,11 +87,13 @@ async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Prom
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
expect(rotated?.token).toBeTruthy();
|
||||
expect(rotated.ok).toBe(true);
|
||||
const pairingToken = rotated.ok ? rotated.entry.token : "";
|
||||
expect(pairingToken).toBeTruthy();
|
||||
return {
|
||||
deviceId: paired.identity.deviceId,
|
||||
identityPath: paired.identityPath,
|
||||
pairingToken: String(rotated?.token ?? ""),
|
||||
pairingToken,
|
||||
};
|
||||
}
|
||||
|
||||
@ -221,7 +223,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(rotate.ok).toBe(false);
|
||||
expect(rotate.error?.message).toBe("missing scope: operator.admin");
|
||||
expect(rotate.error?.message).toBe("device token rotation denied");
|
||||
|
||||
const paired = await getPairedDevice(attacker.deviceId);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
|
||||
@ -266,7 +268,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
});
|
||||
|
||||
expect(rotate.ok).toBe(false);
|
||||
expect(rotate.error?.message).toBe("missing scope: operator.admin");
|
||||
expect(rotate.error?.message).toBe("device token rotation denied");
|
||||
await waitForMacrotasks();
|
||||
expect(sawInvoke).toBe(false);
|
||||
|
||||
@ -281,4 +283,39 @@ describe("gateway device.token.rotate caller scope guard", () => {
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns the same public deny for unknown devices and caller scope failures", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-deny-shape");
|
||||
|
||||
let pairingWs: WebSocket | undefined;
|
||||
try {
|
||||
pairingWs = await connectPairingScopedOperator({
|
||||
port: started.port,
|
||||
identityPath: attacker.identityPath,
|
||||
deviceToken: attacker.pairingToken,
|
||||
});
|
||||
|
||||
const missingScope = await rpcReq(pairingWs, "device.token.rotate", {
|
||||
deviceId: attacker.deviceId,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
const unknownDevice = await rpcReq(pairingWs, "device.token.rotate", {
|
||||
deviceId: "missing-device",
|
||||
role: "operator",
|
||||
scopes: ["operator.pairing"],
|
||||
});
|
||||
|
||||
expect(missingScope.ok).toBe(false);
|
||||
expect(unknownDevice.ok).toBe(false);
|
||||
expect(missingScope.error?.message).toBe("device token rotation denied");
|
||||
expect(unknownDevice.error?.message).toBe("device token rotation denied");
|
||||
} finally {
|
||||
pairingWs?.close();
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
rotateDeviceToken,
|
||||
verifyDeviceToken,
|
||||
type PairedDevice,
|
||||
type RotateDeviceTokenResult,
|
||||
} from "./device-pairing.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
|
||||
@ -55,6 +56,14 @@ function requireToken(token: string | undefined): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireRotatedEntry(result: RotateDeviceTokenResult) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected rotated token entry, got ${result.reason}`);
|
||||
}
|
||||
return result.entry;
|
||||
}
|
||||
|
||||
async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: string[]) {
|
||||
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||
const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
|
||||
@ -204,22 +213,24 @@ describe("device pairing tokens", () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||
|
||||
await rotateDeviceToken({
|
||||
const downscoped = await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
baseDir,
|
||||
});
|
||||
expect(downscoped.ok).toBe(true);
|
||||
let paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
expect(paired?.scopes).toEqual(["operator.admin"]);
|
||||
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
|
||||
|
||||
await rotateDeviceToken({
|
||||
const reused = await rotateDeviceToken({
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
baseDir,
|
||||
});
|
||||
expect(reused.ok).toBe(true);
|
||||
paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||
});
|
||||
@ -255,7 +266,7 @@ describe("device pairing tokens", () => {
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
});
|
||||
expect(rotated).toBeNull();
|
||||
expect(rotated).toEqual({ ok: false, reason: "scope-outside-approved-baseline" });
|
||||
|
||||
const after = await getPairedDevice("device-1", baseDir);
|
||||
expect(after?.tokens?.operator?.token).toEqual(before?.tokens?.operator?.token);
|
||||
@ -357,12 +368,13 @@ describe("device pairing tokens", () => {
|
||||
scopes: ["operator.talk.secrets"],
|
||||
baseDir,
|
||||
});
|
||||
expect(rotated?.scopes).toEqual(["operator.talk.secrets"]);
|
||||
const entry = requireRotatedEntry(rotated);
|
||||
expect(entry.scopes).toEqual(["operator.talk.secrets"]);
|
||||
|
||||
await expect(
|
||||
verifyOperatorToken({
|
||||
baseDir,
|
||||
token: requireToken(rotated?.token),
|
||||
token: requireToken(entry.token),
|
||||
scopes: ["operator.talk.secrets"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
@ -395,7 +407,7 @@ describe("device pairing tokens", () => {
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
).resolves.toEqual({ ok: false, reason: "missing-approved-scope-baseline" });
|
||||
});
|
||||
|
||||
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
||||
|
||||
@ -48,6 +48,15 @@ export type DeviceAuthTokenSummary = {
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type RotateDeviceTokenDenyReason =
|
||||
| "unknown-device-or-role"
|
||||
| "missing-approved-scope-baseline"
|
||||
| "scope-outside-approved-baseline";
|
||||
|
||||
export type RotateDeviceTokenResult =
|
||||
| { ok: true; entry: DeviceAuthToken }
|
||||
| { ok: false; reason: RotateDeviceTokenDenyReason };
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
@ -587,7 +596,7 @@ export async function rotateDeviceToken(params: {
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
baseDir?: string;
|
||||
}): Promise<DeviceAuthToken | null> {
|
||||
}): Promise<RotateDeviceTokenResult> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const context = resolveDeviceTokenUpdateContext({
|
||||
@ -596,13 +605,16 @@ export async function rotateDeviceToken(params: {
|
||||
role: params.role,
|
||||
});
|
||||
if (!context) {
|
||||
return null;
|
||||
return { ok: false, reason: "unknown-device-or-role" };
|
||||
}
|
||||
const { device, role, tokens, existing } = context;
|
||||
const requestedScopes = normalizeDeviceAuthScopes(
|
||||
params.scopes ?? existing?.scopes ?? device.scopes,
|
||||
);
|
||||
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
|
||||
if (!approvedScopes) {
|
||||
return { ok: false, reason: "missing-approved-scope-baseline" };
|
||||
}
|
||||
if (
|
||||
!scopesWithinApprovedDeviceBaseline({
|
||||
role,
|
||||
@ -610,7 +622,7 @@ export async function rotateDeviceToken(params: {
|
||||
approvedScopes,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
return { ok: false, reason: "scope-outside-approved-baseline" };
|
||||
}
|
||||
const now = Date.now();
|
||||
const next = buildDeviceAuthToken({
|
||||
@ -624,7 +636,7 @@ export async function rotateDeviceToken(params: {
|
||||
device.tokens = tokens;
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, params.baseDir);
|
||||
return next;
|
||||
return { ok: true, entry: next };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,8 @@ import type {
|
||||
} from "../config/types.approvals.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
|
||||
import { compileConfigRegex } from "../security/config-regex.js";
|
||||
import { testRegexWithBoundedInput } from "../security/safe-regex.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
@ -63,8 +64,8 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
|
||||
if (sessionKey.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
const regex = compileSafeRegex(pattern);
|
||||
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
|
||||
const compiled = compileConfigRegex(pattern);
|
||||
return compiled?.regex ? testRegexWithBoundedInput(compiled.regex, sessionKey) : false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -47,8 +47,10 @@ export function resolveSafeInstallDir(params: {
|
||||
baseDir: string;
|
||||
id: string;
|
||||
invalidNameMessage: string;
|
||||
nameEncoder?: (id: string) => string;
|
||||
}): { ok: true; path: string } | { ok: false; error: string } {
|
||||
const targetDir = path.join(params.baseDir, safeDirName(params.id));
|
||||
const encodedName = (params.nameEncoder ?? safeDirName)(params.id);
|
||||
const targetDir = path.join(params.baseDir, encodedName);
|
||||
const resolvedBase = path.resolve(params.baseDir);
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
const relative = path.relative(resolvedBase, resolvedTarget);
|
||||
|
||||
@ -7,12 +7,14 @@ export async function resolveCanonicalInstallTarget(params: {
|
||||
id: string;
|
||||
invalidNameMessage: string;
|
||||
boundaryLabel: string;
|
||||
nameEncoder?: (id: string) => string;
|
||||
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
|
||||
await fs.mkdir(params.baseDir, { recursive: true });
|
||||
const targetDirResult = resolveSafeInstallDir({
|
||||
baseDir: params.baseDir,
|
||||
id: params.id,
|
||||
invalidNameMessage: params.invalidNameMessage,
|
||||
nameEncoder: params.nameEncoder,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
return { ok: false, error: targetDirResult.error };
|
||||
|
||||
@ -11,40 +11,50 @@ const createFakeProcess = () =>
|
||||
|
||||
const createWatchHarness = () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(),
|
||||
kill: vi.fn(() => {}),
|
||||
});
|
||||
const spawn = vi.fn(() => child);
|
||||
const watcher = Object.assign(new EventEmitter(), {
|
||||
close: vi.fn(async () => {}),
|
||||
});
|
||||
const createWatcher = vi.fn(() => watcher);
|
||||
const fakeProcess = createFakeProcess();
|
||||
return { child, spawn, fakeProcess };
|
||||
return { child, spawn, watcher, createWatcher, fakeProcess };
|
||||
};
|
||||
|
||||
describe("watch-node script", () => {
|
||||
it("wires node watch to run-node with watched source/config paths", async () => {
|
||||
const { child, spawn, fakeProcess } = createWatchHarness();
|
||||
it("wires chokidar watch to run-node with watched source/config paths", async () => {
|
||||
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/tmp/openclaw",
|
||||
createWatcher,
|
||||
env: { PATH: "/usr/bin" },
|
||||
now: () => 1700000000000,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
|
||||
queueMicrotask(() => child.emit("exit", 0, null));
|
||||
const exitCode = await runPromise;
|
||||
expect(createWatcher).toHaveBeenCalledTimes(1);
|
||||
const firstWatcherCall = createWatcher.mock.calls[0];
|
||||
expect(firstWatcherCall).toBeDefined();
|
||||
const [watchPaths, watchOptions] = firstWatcherCall as unknown as [
|
||||
string[],
|
||||
{ ignoreInitial: boolean; ignored: (watchPath: string) => boolean },
|
||||
];
|
||||
expect(watchPaths).toEqual(runNodeWatchedPaths);
|
||||
expect(watchOptions.ignoreInitial).toBe(true);
|
||||
expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true);
|
||||
expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true);
|
||||
expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true);
|
||||
expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false);
|
||||
expect(watchOptions.ignored("tsconfig.json")).toBe(false);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/node",
|
||||
[
|
||||
...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]),
|
||||
"--watch-preserve-output",
|
||||
"scripts/run-node.mjs",
|
||||
"gateway",
|
||||
"--force",
|
||||
],
|
||||
["scripts/run-node.mjs", "gateway", "--force"],
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/openclaw",
|
||||
stdio: "inherit",
|
||||
@ -56,13 +66,19 @@ describe("watch-node script", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
fakeProcess.emit("SIGINT");
|
||||
const exitCode = await runPromise;
|
||||
expect(exitCode).toBe(130);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(watcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("terminates child on SIGINT and returns shell interrupt code", async () => {
|
||||
const { child, spawn, fakeProcess } = createWatchHarness();
|
||||
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
createWatcher,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
@ -72,15 +88,17 @@ describe("watch-node script", () => {
|
||||
|
||||
expect(exitCode).toBe(130);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(watcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
|
||||
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates child on SIGTERM and returns shell terminate code", async () => {
|
||||
const { child, spawn, fakeProcess } = createWatchHarness();
|
||||
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
createWatcher,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
@ -90,7 +108,74 @@ describe("watch-node script", () => {
|
||||
|
||||
expect(exitCode).toBe(143);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(watcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(fakeProcess.listenerCount("SIGINT")).toBe(0);
|
||||
expect(fakeProcess.listenerCount("SIGTERM")).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores test-only changes and restarts on non-test source changes", async () => {
|
||||
const childA = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(function () {
|
||||
queueMicrotask(() => childA.emit("exit", 0, null));
|
||||
}),
|
||||
});
|
||||
const childB = Object.assign(new EventEmitter(), {
|
||||
kill: vi.fn(() => {}),
|
||||
});
|
||||
const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB);
|
||||
const watcher = Object.assign(new EventEmitter(), {
|
||||
close: vi.fn(async () => {}),
|
||||
});
|
||||
const createWatcher = vi.fn(() => watcher);
|
||||
const fakeProcess = createFakeProcess();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
createWatcher,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
|
||||
watcher.emit("change", "src/infra/watch-node.test.ts");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(childA.kill).not.toHaveBeenCalled();
|
||||
|
||||
watcher.emit("change", "src/infra/watch-node.test.tsx");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(childA.kill).not.toHaveBeenCalled();
|
||||
|
||||
watcher.emit("change", "src/infra/watch-node-test-helpers.ts");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(childA.kill).not.toHaveBeenCalled();
|
||||
|
||||
watcher.emit("change", "src/infra/watch-node.ts");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(childA.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
|
||||
fakeProcess.emit("SIGINT");
|
||||
const exitCode = await runPromise;
|
||||
expect(exitCode).toBe(130);
|
||||
});
|
||||
|
||||
it("kills child and exits when watcher emits an error", async () => {
|
||||
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
|
||||
|
||||
const runPromise = runWatchMain({
|
||||
args: ["gateway", "--force"],
|
||||
createWatcher,
|
||||
process: fakeProcess,
|
||||
spawn,
|
||||
});
|
||||
|
||||
watcher.emit("error", new Error("watch failed"));
|
||||
const exitCode = await runPromise;
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(watcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { compileSafeRegex } from "../security/safe-regex.js";
|
||||
import { compileConfigRegex } from "../security/config-regex.js";
|
||||
import { resolveNodeRequireFromMeta } from "./node-require.js";
|
||||
import { replacePatternBounded } from "./redact-bounded.js";
|
||||
|
||||
@ -55,9 +55,9 @@ function parsePattern(raw: string): RegExp | null {
|
||||
const match = raw.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||
if (match) {
|
||||
const flags = match[2].includes("g") ? match[2] : `${match[2]}g`;
|
||||
return compileSafeRegex(match[1], flags);
|
||||
return compileConfigRegex(match[1], flags)?.regex ?? null;
|
||||
}
|
||||
return compileSafeRegex(raw, "gi");
|
||||
return compileConfigRegex(raw, "gi")?.regex ?? null;
|
||||
}
|
||||
|
||||
function resolvePatterns(value?: string[]): RegExp[] {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js";
|
||||
import { normalizeGoogleModelId } from "../../../agents/model-id-normalization.js";
|
||||
import { parseGeminiAuth } from "../../../infra/gemini-auth.js";
|
||||
import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js";
|
||||
|
||||
|
||||
@ -109,6 +109,36 @@ describe("runBrowserProxyCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts sensitive cdpUrl details in timeout diagnostics", async () => {
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: {
|
||||
running: true,
|
||||
cdpHttp: true,
|
||||
cdpReady: false,
|
||||
cdpUrl:
|
||||
"https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile: "remote",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=https:\/\/example\.com\/chrome\?token=supers…7890\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-timeout browser errors intact", async () => {
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 500,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fsPromises from "node:fs/promises";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
@ -199,7 +200,7 @@ function formatBrowserProxyTimeoutMessage(params: {
|
||||
statusParts.push(`transport=${params.status.transport}`);
|
||||
}
|
||||
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
|
||||
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
|
||||
statusParts.push(`cdpUrl=${redactCdpUrl(params.status.cdpUrl)}`);
|
||||
}
|
||||
parts.push(`status(${statusParts.join(", ")})`);
|
||||
}
|
||||
|
||||
@ -87,6 +87,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
// WhatsApp-specific functions (resolveWhatsAppAccount, whatsappOnboardingAdapter) moved to extensions/whatsapp/src/
|
||||
expect(typeof whatsappSdk.WhatsAppConfigSchema).toBe("object");
|
||||
expect(typeof whatsappSdk.resolveWhatsAppOutboundTarget).toBe("function");
|
||||
expect(typeof whatsappSdk.resolveWhatsAppMentionStripRegexes).toBe("function");
|
||||
expect("resolveWhatsAppMentionStripPatterns" in whatsappSdk).toBe(false);
|
||||
});
|
||||
|
||||
it("exports LINE helpers", () => {
|
||||
|
||||
@ -38,7 +38,7 @@ export {
|
||||
export {
|
||||
createWhatsAppOutboundBase,
|
||||
resolveWhatsAppGroupIntroHint,
|
||||
resolveWhatsAppMentionStripPatterns,
|
||||
resolveWhatsAppMentionStripRegexes,
|
||||
} from "../channels/plugins/whatsapp-shared.js";
|
||||
export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js";
|
||||
export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js";
|
||||
|
||||
@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { safePathSegmentHashed } from "../infra/install-safe-path.js";
|
||||
import * as skillScanner from "../security/skill-scanner.js";
|
||||
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
|
||||
import {
|
||||
@ -20,6 +21,7 @@ let installPluginFromDir: typeof import("./install.js").installPluginFromDir;
|
||||
let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec;
|
||||
let installPluginFromPath: typeof import("./install.js").installPluginFromPath;
|
||||
let PLUGIN_INSTALL_ERROR_CODE: typeof import("./install.js").PLUGIN_INSTALL_ERROR_CODE;
|
||||
let resolvePluginInstallDir: typeof import("./install.js").resolvePluginInstallDir;
|
||||
let runCommandWithTimeout: typeof import("../process/exec.js").runCommandWithTimeout;
|
||||
let suiteTempRoot = "";
|
||||
let suiteFixtureRoot = "";
|
||||
@ -157,7 +159,9 @@ async function setupVoiceCallArchiveInstall(params: { outName: string; version:
|
||||
}
|
||||
|
||||
function expectPluginFiles(result: { targetDir: string }, stateDir: string, pluginId: string) {
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "extensions", pluginId));
|
||||
expect(result.targetDir).toBe(
|
||||
resolvePluginInstallDir(pluginId, path.join(stateDir, "extensions")),
|
||||
);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||
}
|
||||
@ -331,6 +335,7 @@ beforeAll(async () => {
|
||||
installPluginFromNpmSpec,
|
||||
installPluginFromPath,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
resolvePluginInstallDir,
|
||||
} = await import("./install.js"));
|
||||
({ runCommandWithTimeout } = await import("../process/exec.js"));
|
||||
|
||||
@ -394,7 +399,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("installPluginFromArchive", () => {
|
||||
it("installs into ~/.openclaw/extensions and uses unscoped id", async () => {
|
||||
it("installs into ~/.openclaw/extensions and preserves scoped package ids", async () => {
|
||||
const { stateDir, archivePath, extensionsDir } = await setupVoiceCallArchiveInstall({
|
||||
outName: "plugin.tgz",
|
||||
version: "0.0.1",
|
||||
@ -404,7 +409,7 @@ describe("installPluginFromArchive", () => {
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
});
|
||||
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "voice-call" });
|
||||
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/voice-call" });
|
||||
});
|
||||
|
||||
it("rejects installing when plugin already exists", async () => {
|
||||
@ -443,7 +448,7 @@ describe("installPluginFromArchive", () => {
|
||||
archivePath,
|
||||
extensionsDir,
|
||||
});
|
||||
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "zipper" });
|
||||
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/zipper" });
|
||||
});
|
||||
|
||||
it("allows updates when mode is update", async () => {
|
||||
@ -615,16 +620,17 @@ describe("installPluginFromArchive", () => {
|
||||
});
|
||||
|
||||
describe("installPluginFromDir", () => {
|
||||
function expectInstalledAsMemoryCognee(
|
||||
function expectInstalledWithPluginId(
|
||||
result: Awaited<ReturnType<typeof installPluginFromDir>>,
|
||||
extensionsDir: string,
|
||||
pluginId: string,
|
||||
) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("memory-cognee");
|
||||
expect(result.targetDir).toBe(path.join(extensionsDir, "memory-cognee"));
|
||||
expect(result.pluginId).toBe(pluginId);
|
||||
expect(result.targetDir).toBe(resolvePluginInstallDir(pluginId, extensionsDir));
|
||||
}
|
||||
|
||||
it("uses --ignore-scripts for dependency install", async () => {
|
||||
@ -689,17 +695,17 @@ describe("installPluginFromDir", () => {
|
||||
logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} },
|
||||
});
|
||||
|
||||
expectInstalledAsMemoryCognee(res, extensionsDir);
|
||||
expectInstalledWithPluginId(res, extensionsDir, "memory-cognee");
|
||||
expect(
|
||||
infoMessages.some((msg) =>
|
||||
msg.includes(
|
||||
'Plugin manifest id "memory-cognee" differs from npm package name "cognee-openclaw"',
|
||||
'Plugin manifest id "memory-cognee" differs from npm package name "@openclaw/cognee-openclaw"',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes scoped manifest ids to unscoped install keys", async () => {
|
||||
it("preserves scoped manifest ids as install keys", async () => {
|
||||
const { pluginDir, extensionsDir } = setupManifestInstallFixture({
|
||||
manifestId: "@team/memory-cognee",
|
||||
});
|
||||
@ -707,11 +713,62 @@ describe("installPluginFromDir", () => {
|
||||
const res = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
expectedPluginId: "memory-cognee",
|
||||
expectedPluginId: "@team/memory-cognee",
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
|
||||
expectInstalledAsMemoryCognee(res, extensionsDir);
|
||||
expectInstalledWithPluginId(res, extensionsDir, "@team/memory-cognee");
|
||||
});
|
||||
|
||||
it("preserves scoped package names when no plugin manifest id is present", async () => {
|
||||
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
|
||||
|
||||
const res = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
|
||||
});
|
||||
|
||||
it("accepts legacy unscoped expected ids for scoped package names without manifest ids", async () => {
|
||||
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
|
||||
|
||||
const res = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
expectedPluginId: "test-plugin",
|
||||
});
|
||||
|
||||
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
|
||||
});
|
||||
|
||||
it("rejects bare @ as an invalid scoped id", () => {
|
||||
expect(() => resolvePluginInstallDir("@")).toThrow(
|
||||
"invalid plugin name: scoped ids must use @scope/name format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty scoped segments like @/name", () => {
|
||||
expect(() => resolvePluginInstallDir("@/name")).toThrow(
|
||||
"invalid plugin name: scoped ids must use @scope/name format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects two-segment ids without a scope prefix", () => {
|
||||
expect(() => resolvePluginInstallDir("team/name")).toThrow(
|
||||
"invalid plugin name: scoped ids must use @scope/name format",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a unique hashed install dir for scoped ids", () => {
|
||||
const extensionsDir = path.join(makeTempDir(), "extensions");
|
||||
const scopedTarget = resolvePluginInstallDir("@scope/name", extensionsDir);
|
||||
const hashedFlatId = safePathSegmentHashed("@scope/name");
|
||||
const flatTarget = resolvePluginInstallDir(hashedFlatId, extensionsDir);
|
||||
|
||||
expect(path.basename(scopedTarget)).toBe(`@${hashedFlatId}`);
|
||||
expect(scopedTarget).not.toBe(flatTarget);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import { installPackageDir } from "../infra/install-package-dir.js";
|
||||
import {
|
||||
resolveSafeInstallDir,
|
||||
safeDirName,
|
||||
safePathSegmentHashed,
|
||||
unscopedPackageName,
|
||||
} from "../infra/install-safe-path.js";
|
||||
import {
|
||||
@ -84,19 +85,68 @@ function safeFileName(input: string): string {
|
||||
return safeDirName(input);
|
||||
}
|
||||
|
||||
function encodePluginInstallDirName(pluginId: string): string {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed.includes("/")) {
|
||||
return safeDirName(trimmed);
|
||||
}
|
||||
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
|
||||
// with valid unscoped ids that happen to match the hashed slug.
|
||||
return `@${safePathSegmentHashed(trimmed)}`;
|
||||
}
|
||||
|
||||
function validatePluginId(pluginId: string): string | null {
|
||||
if (!pluginId) {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed) {
|
||||
return "invalid plugin name: missing";
|
||||
}
|
||||
if (pluginId === "." || pluginId === "..") {
|
||||
return "invalid plugin name: reserved path segment";
|
||||
}
|
||||
if (pluginId.includes("/") || pluginId.includes("\\")) {
|
||||
if (trimmed.includes("\\")) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
const segments = trimmed.split("/");
|
||||
if (segments.some((segment) => !segment)) {
|
||||
return "invalid plugin name: malformed scope";
|
||||
}
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
return "invalid plugin name: reserved path segment";
|
||||
}
|
||||
if (segments.length === 1) {
|
||||
if (trimmed.startsWith("@")) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (segments.length !== 2) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesExpectedPluginId(params: {
|
||||
expectedPluginId?: string;
|
||||
pluginId: string;
|
||||
manifestPluginId?: string;
|
||||
npmPluginId: string;
|
||||
}): boolean {
|
||||
if (!params.expectedPluginId) {
|
||||
return true;
|
||||
}
|
||||
if (params.expectedPluginId === params.pluginId) {
|
||||
return true;
|
||||
}
|
||||
// Backward compatibility: older install records keyed scoped npm packages by
|
||||
// their unscoped package name. Preserve update-in-place for those records
|
||||
// unless the package declares an explicit manifest id override.
|
||||
return (
|
||||
!params.manifestPluginId &&
|
||||
params.pluginId === params.npmPluginId &&
|
||||
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
|
||||
);
|
||||
}
|
||||
|
||||
function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
|
||||
| {
|
||||
ok: true;
|
||||
@ -195,6 +245,7 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string
|
||||
baseDir: extensionsBase,
|
||||
id: pluginId,
|
||||
invalidNameMessage: "invalid plugin name: path traversal detected",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
throw new Error(targetDirResult.error);
|
||||
@ -233,8 +284,8 @@ async function installPluginFromPackageDir(
|
||||
}
|
||||
const extensions = extensionsResult.entries;
|
||||
|
||||
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
||||
const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
||||
const pkgName = typeof manifest.name === "string" ? manifest.name.trim() : "";
|
||||
const npmPluginId = pkgName || "plugin";
|
||||
|
||||
// Prefer the canonical `id` from openclaw.plugin.json over the npm package name.
|
||||
// This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee")
|
||||
@ -243,7 +294,7 @@ async function installPluginFromPackageDir(
|
||||
const ocManifestResult = loadPluginManifest(params.packageDir);
|
||||
const manifestPluginId =
|
||||
ocManifestResult.ok && ocManifestResult.manifest.id
|
||||
? unscopedPackageName(ocManifestResult.manifest.id)
|
||||
? ocManifestResult.manifest.id.trim()
|
||||
: undefined;
|
||||
|
||||
const pluginId = manifestPluginId ?? npmPluginId;
|
||||
@ -251,7 +302,14 @@ async function installPluginFromPackageDir(
|
||||
if (pluginIdError) {
|
||||
return { ok: false, error: pluginIdError };
|
||||
}
|
||||
if (params.expectedPluginId && params.expectedPluginId !== pluginId) {
|
||||
if (
|
||||
!matchesExpectedPluginId({
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
pluginId,
|
||||
manifestPluginId,
|
||||
npmPluginId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
|
||||
@ -313,6 +371,7 @@ async function installPluginFromPackageDir(
|
||||
id: pluginId,
|
||||
invalidNameMessage: "invalid plugin name: path traversal detected",
|
||||
boundaryLabel: "extensions directory",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
return { ok: false, error: targetDirResult.error };
|
||||
|
||||
@ -1692,7 +1692,37 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(workspacePlugin?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("lets an explicitly trusted workspace plugin shadow a bundled plugin with the same id", () => {
|
||||
it("keeps scoped and unscoped plugin ids distinct", () => {
|
||||
useNoBundledPlugins();
|
||||
const scoped = writePlugin({
|
||||
id: "@team/shadowed",
|
||||
body: `module.exports = { id: "@team/shadowed", register() {} };`,
|
||||
filename: "scoped.cjs",
|
||||
});
|
||||
const unscoped = writePlugin({
|
||||
id: "shadowed",
|
||||
body: `module.exports = { id: "shadowed", register() {} };`,
|
||||
filename: "unscoped.cjs",
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [scoped.file, unscoped.file] },
|
||||
allow: ["@team/shadowed", "shadowed"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "@team/shadowed")?.status).toBe("loaded");
|
||||
expect(registry.plugins.find((entry) => entry.id === "shadowed")?.status).toBe("loaded");
|
||||
expect(
|
||||
registry.diagnostics.some((diag) => String(diag.message).includes("duplicate plugin id")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps bundled plugins ahead of trusted workspace duplicates with the same id", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
writePlugin({
|
||||
id: "shadowed",
|
||||
@ -1719,6 +1749,9 @@ describe("loadOpenClawPlugins", () => {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ["shadowed"],
|
||||
entries: {
|
||||
shadowed: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1726,8 +1759,9 @@ describe("loadOpenClawPlugins", () => {
|
||||
const entries = registry.plugins.filter((entry) => entry.id === "shadowed");
|
||||
const loaded = entries.find((entry) => entry.status === "loaded");
|
||||
const overridden = entries.find((entry) => entry.status === "disabled");
|
||||
expect(loaded?.origin).toBe("workspace");
|
||||
expect(overridden?.origin).toBe("bundled");
|
||||
expect(loaded?.origin).toBe("bundled");
|
||||
expect(overridden?.origin).toBe("workspace");
|
||||
expect(overridden?.error).toContain("overridden by bundled plugin");
|
||||
});
|
||||
|
||||
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {
|
||||
|
||||
@ -485,16 +485,20 @@ function resolveCandidateDuplicateRank(params: {
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
switch (params.candidate.origin) {
|
||||
case "config":
|
||||
return 0;
|
||||
case "workspace":
|
||||
return 1;
|
||||
case "global":
|
||||
return isExplicitInstall ? 2 : 4;
|
||||
case "bundled":
|
||||
return 3;
|
||||
if (params.candidate.origin === "config") {
|
||||
return 0;
|
||||
}
|
||||
if (params.candidate.origin === "global" && isExplicitInstall) {
|
||||
return 1;
|
||||
}
|
||||
if (params.candidate.origin === "bundled") {
|
||||
// Bundled plugin ids stay reserved unless the operator configured an override.
|
||||
return 2;
|
||||
}
|
||||
if (params.candidate.origin === "workspace") {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
|
||||
function compareDuplicateCandidateOrder(params: {
|
||||
|
||||
@ -225,6 +225,36 @@ describe("loadPluginManifestRegistry", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports bundled plugins as the duplicate winner for workspace duplicates", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const workspaceDir = makeTempDir();
|
||||
const manifest = { id: "shadowed", configSchema: { type: "object" } };
|
||||
writeManifest(bundledDir, manifest);
|
||||
writeManifest(workspaceDir, manifest);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "shadowed",
|
||||
rootDir: bundledDir,
|
||||
origin: "bundled",
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "shadowed",
|
||||
rootDir: workspaceDir,
|
||||
origin: "workspace",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.diagnostics.some((diag) =>
|
||||
diag.message.includes("workspace plugin will be overridden by bundled plugin"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {
|
||||
const realDir = makeTempDir();
|
||||
const manifest = { id: "feishu", configSchema: { type: "object" } };
|
||||
|
||||
@ -13,7 +13,8 @@ type SeenIdEntry = {
|
||||
recordIndex: number;
|
||||
};
|
||||
|
||||
// Precedence: config > workspace > explicit-install global > bundled > auto-discovered global
|
||||
// Canonicalize identical physical plugin roots with the most explicit source.
|
||||
// This only applies when multiple candidates resolve to the same on-disk plugin.
|
||||
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
|
||||
config: 0,
|
||||
workspace: 1,
|
||||
@ -167,17 +168,28 @@ function resolveDuplicatePrecedenceRank(params: {
|
||||
config?: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
if (params.candidate.origin === "global") {
|
||||
return matchesInstalledPluginRecord({
|
||||
if (params.candidate.origin === "config") {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
params.candidate.origin === "global" &&
|
||||
matchesInstalledPluginRecord({
|
||||
pluginId: params.pluginId,
|
||||
candidate: params.candidate,
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
})
|
||||
? 2
|
||||
: 4;
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
return PLUGIN_ORIGIN_RANK[params.candidate.origin];
|
||||
if (params.candidate.origin === "bundled") {
|
||||
// Bundled plugin ids are reserved unless the operator explicitly overrides them.
|
||||
return 2;
|
||||
}
|
||||
if (params.candidate.origin === "workspace") {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
|
||||
export function loadPluginManifestRegistry(params: {
|
||||
|
||||
@ -156,6 +156,63 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
|
||||
installPluginFromNpmSpecMock.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "@openclaw/voice-call",
|
||||
targetDir: "/tmp/openclaw-voice-call",
|
||||
version: "0.0.2",
|
||||
extensions: ["index.ts"],
|
||||
});
|
||||
|
||||
const { updateNpmInstalledPlugins } = await import("./update.js");
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["voice-call"],
|
||||
deny: ["voice-call"],
|
||||
slots: { memory: "voice-call" },
|
||||
entries: {
|
||||
"voice-call": {
|
||||
enabled: false,
|
||||
hooks: { allowPromptInjection: false },
|
||||
},
|
||||
},
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginIds: ["voice-call"],
|
||||
});
|
||||
|
||||
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@openclaw/voice-call",
|
||||
expectedPluginId: "voice-call",
|
||||
}),
|
||||
);
|
||||
expect(result.config.plugins?.allow).toEqual(["@openclaw/voice-call"]);
|
||||
expect(result.config.plugins?.deny).toEqual(["@openclaw/voice-call"]);
|
||||
expect(result.config.plugins?.slots?.memory).toBe("@openclaw/voice-call");
|
||||
expect(result.config.plugins?.entries?.["@openclaw/voice-call"]).toEqual({
|
||||
enabled: false,
|
||||
hooks: { allowPromptInjection: false },
|
||||
});
|
||||
expect(result.config.plugins?.entries?.["voice-call"]).toBeUndefined();
|
||||
expect(result.config.plugins?.installs?.["@openclaw/voice-call"]).toMatchObject({
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/openclaw-voice-call",
|
||||
version: "0.0.2",
|
||||
});
|
||||
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncPluginsForUpdateChannel", () => {
|
||||
|
||||
@ -172,6 +172,79 @@ function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = proce
|
||||
};
|
||||
}
|
||||
|
||||
function replacePluginIdInList(
|
||||
entries: string[] | undefined,
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): string[] | undefined {
|
||||
if (!entries || entries.length === 0 || fromId === toId) {
|
||||
return entries;
|
||||
}
|
||||
const next: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const value = entry === fromId ? toId : entry;
|
||||
if (!next.includes(value)) {
|
||||
next.push(value);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig {
|
||||
if (fromId === toId) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const installs = cfg.plugins?.installs;
|
||||
const entries = cfg.plugins?.entries;
|
||||
const slots = cfg.plugins?.slots;
|
||||
const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId);
|
||||
const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId);
|
||||
|
||||
const nextInstalls = installs ? { ...installs } : undefined;
|
||||
if (nextInstalls && fromId in nextInstalls) {
|
||||
const record = nextInstalls[fromId];
|
||||
if (record && !(toId in nextInstalls)) {
|
||||
nextInstalls[toId] = record;
|
||||
}
|
||||
delete nextInstalls[fromId];
|
||||
}
|
||||
|
||||
const nextEntries = entries ? { ...entries } : undefined;
|
||||
if (nextEntries && fromId in nextEntries) {
|
||||
const entry = nextEntries[fromId];
|
||||
if (entry) {
|
||||
nextEntries[toId] = nextEntries[toId]
|
||||
? {
|
||||
...entry,
|
||||
...nextEntries[toId],
|
||||
}
|
||||
: entry;
|
||||
}
|
||||
delete nextEntries[fromId];
|
||||
}
|
||||
|
||||
const nextSlots =
|
||||
slots?.memory === fromId
|
||||
? {
|
||||
...slots,
|
||||
memory: toId,
|
||||
}
|
||||
: slots;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
allow,
|
||||
deny,
|
||||
entries: nextEntries,
|
||||
installs: nextInstalls,
|
||||
slots: nextSlots,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginUpdateIntegrityDriftHandler(params: {
|
||||
pluginId: string;
|
||||
dryRun: boolean;
|
||||
@ -362,9 +435,14 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPluginId = result.pluginId;
|
||||
if (resolvedPluginId !== pluginId) {
|
||||
next = migratePluginConfigId(next, pluginId, resolvedPluginId);
|
||||
}
|
||||
|
||||
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId,
|
||||
pluginId: resolvedPluginId,
|
||||
source: "npm",
|
||||
spec: record.spec,
|
||||
installPath: result.targetDir,
|
||||
|
||||
@ -1378,6 +1378,32 @@ description: test skill
|
||||
expectFinding(res, "browser.remote_cdp_http", "warn");
|
||||
});
|
||||
|
||||
it("warns when remote CDP targets a private/internal host", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
browser: {
|
||||
profiles: {
|
||||
remote: {
|
||||
cdpUrl:
|
||||
"http://169.254.169.254:9222/json/version?token=supersecrettokenvalue1234567890",
|
||||
color: "#0066CC",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
|
||||
expectFinding(res, "browser.remote_cdp_private_host", "warn");
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "browser.remote_cdp_private_host",
|
||||
detail: expect.stringContaining("token=supers…7890"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when control UI allows insecure auth", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
|
||||
@ -2,6 +2,7 @@ import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js";
|
||||
import { execDockerRaw } from "../agents/sandbox/docker.js";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||
import { resolveBrowserControlAuth } from "../browser/control-auth.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
@ -18,6 +19,7 @@ import {
|
||||
resolveMergedSafeBinProfileFixtures,
|
||||
} from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import { isBlockedHostnameOrIp, isPrivateNetworkAllowedByPolicy } from "../infra/net/ssrf.js";
|
||||
import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
import {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
@ -782,15 +784,31 @@ function collectBrowserControlFindings(
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const redactedCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
|
||||
if (url.protocol === "http:") {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_http",
|
||||
severity: "warn",
|
||||
title: "Remote CDP uses HTTP",
|
||||
detail: `browser profile "${name}" uses http CDP (${profile.cdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
|
||||
detail: `browser profile "${name}" uses http CDP (${redactedCdpUrl}); this is OK only if it's tailnet-only or behind an encrypted tunnel.`,
|
||||
remediation: `Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP.`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isPrivateNetworkAllowedByPolicy(resolved.ssrfPolicy) &&
|
||||
isBlockedHostnameOrIp(url.hostname)
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "browser.remote_cdp_private_host",
|
||||
severity: "warn",
|
||||
title: "Remote CDP targets a private/internal host",
|
||||
detail:
|
||||
`browser profile "${name}" points at a private/internal CDP host (${redactedCdpUrl}). ` +
|
||||
"This is expected for LAN/tailnet/WSL-style setups, but treat it as a trusted-network endpoint.",
|
||||
remediation:
|
||||
"Prefer a tailnet or tunnel for remote CDP. If you want strict blocking, set browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=false and allow only explicit hosts.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user