mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 14:32:03 +08:00
Compare commits
2 Commits
fix/daemon
...
fix/telegr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0872ba2499 | ||
|
|
836ad14244 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -94,7 +94,7 @@ USER.md
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
.claude/
|
||||
.claude/settings.local.json
|
||||
.agents/
|
||||
.agents
|
||||
.agent/
|
||||
|
||||
@@ -114,17 +114,6 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
@@ -134,7 +123,14 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPromptMatch(ctx, match);
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
@@ -181,7 +177,14 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderPromptMatch(ctx, match);
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
|
||||
337
CHANGELOG.md
337
CHANGELOG.md
@@ -7,79 +7,94 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
|
||||
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- CLI/Config: add `openclaw config file` to print the active config file path resolved from `OPENCLAW_CONFIG_PATH` or the default location. (#26256) thanks @cyb1278588254.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674) Thanks @liuweifly.
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
|
||||
### Fixes
|
||||
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Daemon/macOS TLS trust defaults: set `NODE_USE_SYSTEM_CA=1` by default in gateway/node supervised service environments on macOS (while preserving explicit env overrides), so launchd-managed installs trust enterprise system keychains without manual shell env wiring. (#32205) Thanks @magos-minor.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
|
||||
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
|
||||
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
@@ -94,121 +109,31 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
|
||||
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
|
||||
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
|
||||
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
|
||||
- Slack/session routing: keep top-level channel messages in one shared session when `replyToMode=off`, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- CLI/Config: add `openclaw config file` to print the active config file path resolved from `OPENCLAW_CONFIG_PATH` or the default location. (#26256) thanks @cyb1278588254.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674) Thanks @liuweifly.
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
- Android/Photos permissions: declare Android 14+ selected-photo access permission (`READ_MEDIA_VISUAL_USER_SELECTED`) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
|
||||
@@ -227,7 +152,17 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
|
||||
@@ -239,24 +174,54 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
|
||||
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
|
||||
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Model directives/Auth profiles: split `/model` profile suffixes at the first `@` after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Ollama/Embedded runner base URL precedence: prioritize configured provider `baseUrl` over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
|
||||
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
@@ -674,8 +639,28 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
|
||||
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
|
||||
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
|
||||
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
|
||||
- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
|
||||
- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
|
||||
- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
|
||||
- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
|
||||
- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
|
||||
- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||
- CLI/Memory search: accept `--query <text>` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
## 2026.2.23
|
||||
@@ -729,6 +714,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
|
||||
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
|
||||
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
|
||||
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
|
||||
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) Thanks @steipete.
|
||||
@@ -985,8 +971,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting.
|
||||
- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
|
||||
- Security/MSTeams auth redirect scoping: strip bearer auth on redirect hops outside `authAllowHosts` and gate SharePoint Graph auth-header injection by auth allowlist to prevent token bleed across redirect targets. (#25045) Thanks @bmendonca3.
|
||||
- MSTeams/reply reliability: when Bot Framework revokes thread turn-context proxies (for example debounced flush paths), fall back to proactive messaging/typing and continue pending sends without duplicating already delivered messages. (#27224) Thanks @openperf.
|
||||
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
|
||||
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
|
||||
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
|
||||
@@ -1092,6 +1076,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
|
||||
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
|
||||
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
|
||||
- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||
- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
|
||||
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
|
||||
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && <command>` does not keep stale `(in <dir>)` context in summaries. (#21925) Thanks @Lukavyi.
|
||||
@@ -1464,6 +1450,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
@@ -1473,6 +1460,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
@@ -1963,6 +1951,9 @@ Docs: https://docs.openclaw.ai
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
|
||||
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
|
||||
@@ -2080,6 +2071,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.
|
||||
@@ -2149,10 +2141,62 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.1.31
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)
|
||||
- Telegram: use shared pairing store. (#6127) Thanks @obviyus.
|
||||
- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.
|
||||
- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.
|
||||
- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).
|
||||
- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.
|
||||
- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)
|
||||
- Auth: update MiniMax OAuth hint + portal auth note copy.
|
||||
- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.
|
||||
- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.
|
||||
- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.
|
||||
- Web UI: refine chat layout + extend session active duration.
|
||||
- CI: add formal conformance + alias consistency checks. (#5723, #5807)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
||||
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
|
||||
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
||||
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
|
||||
- Streaming: stabilize partial streaming filters.
|
||||
- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
|
||||
- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).
|
||||
- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings.
|
||||
- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)
|
||||
- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
|
||||
- Lint: satisfy curly rule after import sorting. (#6310)
|
||||
- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso.
|
||||
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
|
||||
- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)
|
||||
- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)
|
||||
- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).
|
||||
- Agents: ensure OpenRouter attribution headers apply in the embedded runner.
|
||||
- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.
|
||||
- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)
|
||||
- Agents: fix Pi prompt template argument syntax. (#6543)
|
||||
- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)
|
||||
- Teams: gate media auth retries.
|
||||
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
||||
- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.
|
||||
- TUI: prevent crash when searching with digits in the model selector.
|
||||
- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.
|
||||
- Browser: secure Chrome extension relay CDP sessions.
|
||||
- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.
|
||||
- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403.
|
||||
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
|
||||
- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)
|
||||
- Security: restrict MEDIA path extraction to prevent LFI. (#4930)
|
||||
- Security: validate message-tool filePath/path against sandbox root. (#6398)
|
||||
- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.
|
||||
- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.
|
||||
- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.
|
||||
|
||||
## 2026.1.30
|
||||
|
||||
@@ -2883,6 +2927,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -2891,6 +2936,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
||||
- Telegram: default reaction notifications to own.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
@@ -2930,6 +2976,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
@@ -3026,7 +3073,13 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
|
||||
#### macOS / Apps
|
||||
|
||||
|
||||
@@ -41,19 +41,6 @@ Examples:
|
||||
- `agent:main:telegram:group:-1001234567890:topic:42`
|
||||
- `agent:main:discord:channel:123456:thread:987654`
|
||||
|
||||
## Main DM route pinning
|
||||
|
||||
When `session.dmScope` is `main`, direct messages may share one main session.
|
||||
To prevent the session’s `lastRoute` from being overwritten by non-owner DMs,
|
||||
OpenClaw infers a pinned owner from `allowFrom` when all of these are true:
|
||||
|
||||
- `allowFrom` has exactly one non-wildcard entry.
|
||||
- The entry can be normalized to a concrete sender ID for that channel.
|
||||
- The inbound DM sender does not match that pinned owner.
|
||||
|
||||
In that mismatch case, OpenClaw still records inbound session metadata, but it
|
||||
skips updating the main session `lastRoute`.
|
||||
|
||||
## Routing rules (how an agent is chosen)
|
||||
|
||||
Routing picks **one agent** for each inbound message:
|
||||
|
||||
@@ -107,28 +107,6 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
### Group mention gating
|
||||
|
||||
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
|
||||
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
|
||||
- This applies both to allowlisted groups and open group mode.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"*": { allow: true, requireMention: true },
|
||||
"Work Chat": { allow: true, requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
|
||||
Accounts map to `zalouser` profiles in OpenClaw state. Example:
|
||||
@@ -147,14 +125,6 @@ Accounts map to `zalouser` profiles in OpenClaw state. Example:
|
||||
}
|
||||
```
|
||||
|
||||
## Typing, reactions, and delivery acknowledgements
|
||||
|
||||
- OpenClaw sends a typing event before dispatching a reply (best-effort).
|
||||
- Message reaction action `react` is supported for `zalouser` in channel actions.
|
||||
- Use `remove: true` to remove a specific reaction emoji from a message.
|
||||
- Reaction semantics: [Reactions](/tools/reactions)
|
||||
- For inbound messages that include event metadata, OpenClaw sends delivered + seen acknowledgements (best-effort).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Login doesn't stick:**
|
||||
|
||||
@@ -48,10 +48,6 @@ Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
||||
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
|
||||
|
||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
||||
installs the bundled plugin directly. To install an npm package with the same
|
||||
name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
@@ -1177,35 +1177,6 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived
|
||||
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
|
||||
- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
|
||||
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
|
||||
- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts:
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-gpu`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-zygote`
|
||||
- `--metrics-recording-only`
|
||||
- `--disable-extensions` (default enabled)
|
||||
- `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are
|
||||
enabled by default and can be disabled with
|
||||
`OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it.
|
||||
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow
|
||||
depends on them.
|
||||
- `--renderer-process-limit=2` can be changed with
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`; set `0` to use Chromium's
|
||||
default process limit.
|
||||
- plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
|
||||
- Defaults are the container image baseline; use a custom browser image with a custom
|
||||
entrypoint to change container defaults.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -2280,7 +2251,6 @@ See [Plugins](/tools/plugin).
|
||||
color: "#FF4500",
|
||||
// headless: false,
|
||||
// noSandbox: false,
|
||||
// extraArgs: [],
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false,
|
||||
},
|
||||
@@ -2295,8 +2265,6 @@ See [Plugins](/tools/plugin).
|
||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
||||
`--disable-gpu`, window sizing, or debug flags).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -148,40 +148,6 @@ scripts/sandbox-browser-setup.sh
|
||||
By default, sandbox containers run with **no network**.
|
||||
Override with `agents.defaults.sandbox.docker.network`.
|
||||
|
||||
The bundled sandbox browser image also applies conservative Chromium startup defaults
|
||||
for containerized workloads. Current container defaults include:
|
||||
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-gpu`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-extensions`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--no-zygote`
|
||||
- `--metrics-recording-only`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
|
||||
- The three graphics hardening flags (`--disable-3d-apis`,
|
||||
`--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful
|
||||
when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0`
|
||||
if your workload requires WebGL or other 3D/browser features.
|
||||
- `--disable-extensions` is enabled by default and can be disabled with
|
||||
`OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
|
||||
- `--renderer-process-limit=2` is controlled by
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>`, where `0` keeps Chromium's default.
|
||||
|
||||
If you need a different runtime profile, use a custom browser image and provide
|
||||
your own entrypoint. For local (non-container) Chromium profiles, use
|
||||
`browser.extraArgs` to append additional startup flags.
|
||||
|
||||
Security defaults:
|
||||
|
||||
- `network: "host"` is blocked.
|
||||
|
||||
@@ -40,31 +40,6 @@ If you see:
|
||||
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
|
||||
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
|
||||
|
||||
## Plugin install fails with missing openclaw extensions
|
||||
|
||||
If install fails with `package.json missing openclaw.extensions`, the plugin package
|
||||
is using an old shape that OpenClaw no longer accepts.
|
||||
|
||||
Fix in the plugin package:
|
||||
|
||||
1. Add `openclaw.extensions` to `package.json`.
|
||||
2. Point entries at built runtime files (usually `./dist/index.js`).
|
||||
3. Republish the plugin and run `openclaw plugins install <npm-spec>` again.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@openclaw/my-plugin",
|
||||
"version": "1.2.3",
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/index.js"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [/tools/plugin#distribution-npm](/tools/plugin#distribution-npm)
|
||||
|
||||
## Decision tree
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -64,13 +64,6 @@ Optional env vars:
|
||||
- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
|
||||
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
|
||||
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
|
||||
- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags
|
||||
`--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need
|
||||
WebGL/3D compatibility.
|
||||
- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser
|
||||
flows require them (default keeps extensions disabled in sandbox browser).
|
||||
- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=<N>` — set Chromium renderer process
|
||||
limit; set to `0` to skip the flag and use Chromium default behavior.
|
||||
|
||||
After it finishes:
|
||||
|
||||
@@ -679,38 +672,6 @@ Notes:
|
||||
- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`.
|
||||
- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`).
|
||||
- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query).
|
||||
- Browser container startup defaults are conservative for shared/container workloads, including:
|
||||
- `--remote-debugging-address=127.0.0.1`
|
||||
- `--remote-debugging-port=<derived from OPENCLAW_BROWSER_CDP_PORT>`
|
||||
- `--user-data-dir=${HOME}/.chrome`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--disable-3d-apis`
|
||||
- `--disable-software-rasterizer`
|
||||
- `--disable-gpu`
|
||||
- `--disable-dev-shm-usage`
|
||||
- `--disable-background-networking`
|
||||
- `--disable-features=TranslateUI`
|
||||
- `--disable-breakpad`
|
||||
- `--disable-crash-reporter`
|
||||
- `--metrics-recording-only`
|
||||
- `--renderer-process-limit=2`
|
||||
- `--no-zygote`
|
||||
- `--disable-extensions`
|
||||
- If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and
|
||||
`--disable-setuid-sandbox` are also appended.
|
||||
- The three graphics hardening flags above are optional. If your workload needs
|
||||
WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without
|
||||
`--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`.
|
||||
- Extension behavior is controlled by `--disable-extensions` and can be disabled
|
||||
(enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for
|
||||
extension-dependent pages or extensions-heavy workflows.
|
||||
- `--renderer-process-limit=2` is also configurable with
|
||||
`OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its
|
||||
default process limit when browser concurrency needs tuning.
|
||||
|
||||
Defaults are applied by default in the bundled image. If you need different
|
||||
Chromium flags, use a custom browser image and provide your own entrypoint.
|
||||
|
||||
Use config:
|
||||
|
||||
|
||||
@@ -109,23 +109,6 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
}
|
||||
```
|
||||
|
||||
### Echo transcript to chat (opt-in)
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
echoTranscript: true, // default is false
|
||||
echoFormat: '📝 "{transcript}"', // optional, supports {transcript}
|
||||
models: [{ provider: "openai", model: "gpt-4o-mini-transcribe" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
@@ -134,26 +117,12 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
- Mistral setup details: [Mistral](/providers/mistral).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Tiny/empty audio files below 1024 bytes are skipped before provider/CLI transcription.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- `tools.media.audio.echoTranscript` is off by default; enable it to send transcript confirmation back to the originating chat before agent processing.
|
||||
- `tools.media.audio.echoFormat` customizes the echo text (placeholder: `{transcript}`).
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
### Proxy environment support
|
||||
|
||||
Provider-based audio transcription honors standard outbound proxy env vars:
|
||||
|
||||
- `HTTPS_PROXY`
|
||||
- `HTTP_PROXY`
|
||||
- `https_proxy`
|
||||
- `http_proxy`
|
||||
|
||||
If no proxy env vars are set, direct egress is used. If proxy config is malformed, OpenClaw logs a warning and falls back to direct fetch.
|
||||
|
||||
## Mention Detection in Groups
|
||||
|
||||
When `requireMention: true` is set for a group chat, OpenClaw now transcribes audio **before** checking for mentions. This allows voice notes to be processed even when they contain mentions.
|
||||
|
||||
@@ -40,7 +40,6 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- audio transcript echo controls (`echoTranscript`, default `false`; `echoFormat`)
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
@@ -58,8 +57,6 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
},
|
||||
audio: {
|
||||
/* optional overrides */
|
||||
echoTranscript: true,
|
||||
echoFormat: '📝 "{transcript}"',
|
||||
},
|
||||
video: {
|
||||
/* optional overrides */
|
||||
@@ -126,7 +123,6 @@ Recommended defaults:
|
||||
Rules:
|
||||
|
||||
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
|
||||
- Audio files smaller than **1024 bytes** are treated as empty/corrupt and skipped before provider/CLI transcription.
|
||||
- If the model returns more than `maxChars`, output is trimmed.
|
||||
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
|
||||
- If `<capability>.enabled: true` but no models are configured, OpenClaw tries the
|
||||
@@ -164,20 +160,6 @@ To disable auto-detection, set:
|
||||
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
### Proxy environment support (provider models)
|
||||
|
||||
When provider-based **audio** and **video** media understanding is enabled, OpenClaw
|
||||
honors standard outbound proxy environment variables for provider HTTP calls:
|
||||
|
||||
- `HTTPS_PROXY`
|
||||
- `HTTP_PROXY`
|
||||
- `https_proxy`
|
||||
- `http_proxy`
|
||||
|
||||
If no proxy env vars are set, media understanding uses direct egress.
|
||||
If the proxy value is malformed, OpenClaw logs a warning and falls back to direct
|
||||
fetch.
|
||||
|
||||
## Capabilities (optional)
|
||||
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
|
||||
@@ -73,5 +73,3 @@ openclaw directory peers list --channel zalouser --query "name"
|
||||
Tool name: `zalouser`
|
||||
|
||||
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
Channel message actions also support `react` for message reactions.
|
||||
|
||||
@@ -90,22 +90,6 @@ Notes:
|
||||
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
|
||||
- Edge TTS is not supported for telephony.
|
||||
|
||||
For STT/transcription, plugins can call:
|
||||
|
||||
```ts
|
||||
const { text } = await api.runtime.stt.transcribeAudioFile({
|
||||
filePath: "/tmp/inbound-audio.ogg",
|
||||
cfg: api.config,
|
||||
// Optional when MIME cannot be inferred reliably:
|
||||
mime: "audio/ogg",
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
|
||||
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
|
||||
|
||||
## Discovery & precedence
|
||||
|
||||
OpenClaw scans, in order:
|
||||
|
||||
@@ -19,5 +19,4 @@ Channel notes:
|
||||
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
|
||||
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||
- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
|
||||
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
|
||||
|
||||
@@ -76,28 +76,6 @@ function resolveVersionFromPackage(command: string, cwd: string): string | null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVersionCheckResult(params: {
|
||||
expectedVersion?: string;
|
||||
installedVersion: string;
|
||||
installCommand: string;
|
||||
}): AcpxVersionCheckResult {
|
||||
if (params.expectedVersion && params.installedVersion !== params.expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${params.installedVersion}, expected ${params.expectedVersion}`,
|
||||
expectedVersion: params.expectedVersion,
|
||||
installCommand: params.installCommand,
|
||||
installedVersion: params.installedVersion,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
version: params.installedVersion,
|
||||
expectedVersion: params.expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkAcpxVersion(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
@@ -153,7 +131,21 @@ export async function checkAcpxVersion(params: {
|
||||
if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) {
|
||||
const installedVersion = resolveVersionFromPackage(params.command, cwd);
|
||||
if (installedVersion) {
|
||||
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
|
||||
if (expectedVersion && installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
const stderr = result.stderr.trim();
|
||||
@@ -187,7 +179,22 @@ export async function checkAcpxVersion(params: {
|
||||
};
|
||||
}
|
||||
|
||||
return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
|
||||
if (expectedVersion && installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
let pendingEnsure: Promise<void> | null = null;
|
||||
|
||||
@@ -14,8 +14,6 @@ export const NOOP_LOGGER = {
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let sharedMockCliScriptPath: Promise<string> | null = null;
|
||||
let logFileSequence = 0;
|
||||
|
||||
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -265,9 +263,14 @@ export async function createMockRuntimeFixture(params?: {
|
||||
logPath: string;
|
||||
config: ResolvedAcpxPluginConfig;
|
||||
}> {
|
||||
const scriptPath = await ensureMockCliScriptPath();
|
||||
const dir = path.dirname(scriptPath);
|
||||
const logPath = path.join(dir, `calls-${logFileSequence++}.log`);
|
||||
const dir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
|
||||
);
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
const logPath = path.join(dir, "calls.log");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
process.env.MOCK_ACPX_LOG = logPath;
|
||||
|
||||
const config: ResolvedAcpxPluginConfig = {
|
||||
@@ -291,23 +294,6 @@ export async function createMockRuntimeFixture(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMockCliScriptPath(): Promise<string> {
|
||||
if (sharedMockCliScriptPath) {
|
||||
return await sharedMockCliScriptPath;
|
||||
}
|
||||
sharedMockCliScriptPath = (async () => {
|
||||
const dir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
|
||||
);
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
})();
|
||||
return await sharedMockCliScriptPath;
|
||||
}
|
||||
|
||||
export async function readMockRuntimeLogEntries(
|
||||
logPath: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
@@ -324,8 +310,6 @@ export async function readMockRuntimeLogEntries(
|
||||
|
||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
sharedMockCliScriptPath = null;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import {
|
||||
cleanupMockRuntimeFixtures,
|
||||
@@ -10,14 +10,7 @@ import {
|
||||
} from "./runtime-internals/test-fixtures.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
sharedFixture = await createMockRuntimeFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
sharedFixture = null;
|
||||
await cleanupMockRuntimeFixtures();
|
||||
});
|
||||
|
||||
@@ -28,14 +21,20 @@ describe("AcpxRuntime", () => {
|
||||
createRuntime: async () => fixture.runtime,
|
||||
agentId: "codex",
|
||||
successPrompt: "contract-pass",
|
||||
includeControlChecks: false,
|
||||
errorPrompt: "trigger-error",
|
||||
assertSuccessEvents: (events) => {
|
||||
expect(events.some((event) => event.type === "done")).toBe(true);
|
||||
},
|
||||
assertErrorOutcome: ({ events, thrown }) => {
|
||||
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
|
||||
});
|
||||
@@ -111,12 +110,34 @@ describe("AcpxRuntime", () => {
|
||||
expect(promptArgs).toContain("--approve-all");
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:ttl-default",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "ttl-default",
|
||||
mode: "prompt",
|
||||
requestId: "req-ttl-default",
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:space",
|
||||
agent: "codex",
|
||||
@@ -137,28 +158,10 @@ describe("AcpxRuntime", () => {
|
||||
|
||||
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
||||
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
||||
|
||||
// Keep the default queue-owner TTL assertion on a runTurn that already exists.
|
||||
const activeLogPath = process.env.MOCK_ACPX_LOG;
|
||||
expect(activeLogPath).toBeDefined();
|
||||
const logs = await readMockRuntimeLogEntries(String(activeLogPath));
|
||||
const prompt = logs.find(
|
||||
(entry) =>
|
||||
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space",
|
||||
);
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("emits done once when ACP stream repeats stop reason responses", async () => {
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
}
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:double-done",
|
||||
agent: "codex",
|
||||
@@ -180,11 +183,7 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("maps acpx error events into ACP runtime error events", async () => {
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
if (!runtime) {
|
||||
throw new Error("shared runtime fixture missing");
|
||||
}
|
||||
const { runtime } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:456",
|
||||
agent: "codex",
|
||||
|
||||
@@ -120,9 +120,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
tts: {
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
},
|
||||
stt: {
|
||||
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
|
||||
},
|
||||
tools: {
|
||||
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
||||
createMemorySearchTool:
|
||||
|
||||
@@ -185,23 +185,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses internal block payload delivery", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses streaming session for auto mode markdown payloads", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
|
||||
@@ -192,12 +192,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
void typingCallbacks.onReplyStart?.();
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info) => {
|
||||
// FIX: Filter out internal 'block' reasoning chunks immediately to prevent
|
||||
// data leak and race conditions with streaming state initialization.
|
||||
if (info?.kind === "block") {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = payload.text ?? "";
|
||||
const mediaList =
|
||||
payload.mediaUrls && payload.mediaUrls.length > 0
|
||||
@@ -215,7 +209,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
if (hasText) {
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
|
||||
if (info?.kind === "final" && streamingEnabled && useCard) {
|
||||
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
|
||||
startStreaming();
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -11,10 +10,6 @@ const plugin = {
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
|
||||
});
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { createMatrixClient } from "./client/create-client.js";
|
||||
import { startMatrixClientWithGrace } from "./client/startup.js";
|
||||
import { getMatrixLogService } from "./sdk-runtime.js";
|
||||
|
||||
type MatrixClientBootstrapAuth = {
|
||||
homeserver: string;
|
||||
@@ -39,7 +39,6 @@ export async function createPreparedMatrixClient(opts: {
|
||||
await startMatrixClientWithGrace({
|
||||
client,
|
||||
onError: (err: unknown) => {
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.error("MatrixClientBootstrap", "client.start() error:", err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
@@ -119,7 +119,6 @@ export async function resolveMatrixAuth(params?: {
|
||||
if (!userId) {
|
||||
// Fetch userId from access token via whoami
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const { MatrixClient } = loadMatrixSdk();
|
||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||
const whoami = await tempClient.getUserId();
|
||||
userId = whoami;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import type {
|
||||
IStorageProvider,
|
||||
ICryptoStorageProvider,
|
||||
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
LogService,
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
RustSdkCryptoStorageProvider,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from "./storage.js";
|
||||
|
||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||
const LogService = loadMatrixSdk().LogService;
|
||||
if (input == null) {
|
||||
return [];
|
||||
}
|
||||
@@ -44,8 +44,6 @@ export async function createMatrixClient(params: {
|
||||
localTimeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixClient> {
|
||||
const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } =
|
||||
loadMatrixSdk();
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkBaseLogger:
|
||||
| {
|
||||
trace: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
debug: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
info: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
warn: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
error: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
}
|
||||
| undefined;
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||
if (module !== "MatrixHttpClient") {
|
||||
@@ -27,20 +19,18 @@ export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (matrixSdkLoggingConfigured) {
|
||||
return;
|
||||
}
|
||||
const { ConsoleLogger, LogService } = loadMatrixSdk();
|
||||
matrixSdkBaseLogger = new ConsoleLogger();
|
||||
matrixSdkLoggingConfigured = true;
|
||||
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger?.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger?.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger?.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger?.warn(module, ...messageOrObject),
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||
error: (module, ...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
matrixSdkBaseLogger?.error(module, ...messageOrObject);
|
||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixLogService } from "../sdk-runtime.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { startMatrixClientWithGrace } from "./startup.js";
|
||||
@@ -81,7 +81,6 @@ async function ensureSharedClientStarted(params: {
|
||||
params.state.cryptoReady = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +89,6 @@ async function ensureSharedClientStarted(params: {
|
||||
client,
|
||||
onError: (err: unknown) => {
|
||||
params.state.started = false;
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.error("MatrixClientLite", "client.start() error:", err);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
|
||||
const logStub = vi.fn();
|
||||
|
||||
describe("ensureMatrixCryptoRuntime", () => {
|
||||
it("returns immediately when matrix SDK loads", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const requireFn = vi.fn(() => ({}));
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps missing crypto runtime and retries matrix SDK load", async () => {
|
||||
let bootstrapped = false;
|
||||
const requireFn = vi.fn(() => {
|
||||
if (!bootstrapped) {
|
||||
throw new Error(
|
||||
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const runCommand = vi.fn(async () => {
|
||||
bootstrapped = true;
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith({
|
||||
argv: ["/usr/bin/node", "/tmp/download-lib.js"],
|
||||
cwd: "/tmp",
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rethrows non-crypto module errors without bootstrapping", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const requireFn = vi.fn(() => {
|
||||
throw new Error("Cannot find module '@vector-im/matrix-bot-sdk'");
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
}),
|
||||
).rejects.toThrow("Cannot find module '@vector-im/matrix-bot-sdk'");
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,27 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
||||
const MATRIX_CRYPTO_DOWNLOAD_HELPER = "@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js";
|
||||
|
||||
function formatCommandError(result: { stderr: string; stdout: string }): string {
|
||||
const stderr = result.stderr.trim();
|
||||
if (stderr) {
|
||||
return stderr;
|
||||
}
|
||||
const stdout = result.stdout.trim();
|
||||
if (stdout) {
|
||||
return stdout;
|
||||
}
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
function isMissingMatrixCryptoRuntimeError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||
return (
|
||||
message.includes("Cannot find module") &&
|
||||
message.includes("@matrix-org/matrix-sdk-crypto-nodejs-")
|
||||
);
|
||||
}
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
try {
|
||||
@@ -42,51 +21,6 @@ function resolvePluginRoot(): string {
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
}
|
||||
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: {
|
||||
log?: (message: string) => void;
|
||||
requireFn?: (id: string) => unknown;
|
||||
resolveFn?: (id: string) => string;
|
||||
runCommand?: typeof runPluginCommandWithTimeout;
|
||||
nodeExecutable?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const req = createRequire(import.meta.url);
|
||||
const requireFn = params.requireFn ?? ((id: string) => req(id));
|
||||
const resolveFn = params.resolveFn ?? ((id: string) => req.resolve(id));
|
||||
const runCommand = params.runCommand ?? runPluginCommandWithTimeout;
|
||||
const nodeExecutable = params.nodeExecutable ?? process.execPath;
|
||||
|
||||
try {
|
||||
requireFn(MATRIX_SDK_PACKAGE);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isMissingMatrixCryptoRuntimeError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptPath = resolveFn(MATRIX_CRYPTO_DOWNLOAD_HELPER);
|
||||
params.log?.("matrix: crypto runtime missing; downloading platform library…");
|
||||
const result = await runCommand({
|
||||
argv: [nodeExecutable, scriptPath],
|
||||
cwd: path.dirname(scriptPath),
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Matrix crypto runtime bootstrap failed: ${formatCommandError(result)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
requireFn(MATRIX_SDK_PACKAGE);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Matrix crypto runtime remains unavailable after bootstrap: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureMatrixSdkInstalled(params: {
|
||||
runtime: RuntimeEnv;
|
||||
confirm?: (message: string) => Promise<boolean>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
@@ -26,7 +26,6 @@ export function registerMatrixAutoJoin(params: {
|
||||
|
||||
if (autoJoin === "always") {
|
||||
// Use the built-in autojoin mixin for "always" mode
|
||||
const { AutojoinRoomsMixin } = loadMatrixSdk();
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
logVerbose("matrix: auto-join enabled for all invites");
|
||||
return;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
type MatrixSdkRuntime = typeof import("@vector-im/matrix-bot-sdk");
|
||||
|
||||
let cachedMatrixSdkRuntime: MatrixSdkRuntime | null = null;
|
||||
|
||||
export function loadMatrixSdk(): MatrixSdkRuntime {
|
||||
if (cachedMatrixSdkRuntime) {
|
||||
return cachedMatrixSdkRuntime;
|
||||
}
|
||||
const req = createRequire(import.meta.url);
|
||||
cachedMatrixSdkRuntime = req("@vector-im/matrix-bot-sdk") as MatrixSdkRuntime;
|
||||
return cachedMatrixSdkRuntime;
|
||||
}
|
||||
|
||||
export function getMatrixLogService() {
|
||||
return loadMatrixSdk().LogService;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
|
||||
export const DEFAULT_SEND_GAP_MS = 150;
|
||||
|
||||
type MatrixSendQueueOptions = {
|
||||
@@ -8,19 +6,37 @@ type MatrixSendQueueOptions = {
|
||||
};
|
||||
|
||||
// Serialize sends per room to preserve Matrix delivery order.
|
||||
const roomQueues = new KeyedAsyncQueue();
|
||||
const roomQueues = new Map<string, Promise<void>>();
|
||||
|
||||
export function enqueueSend<T>(
|
||||
export async function enqueueSend<T>(
|
||||
roomId: string,
|
||||
fn: () => Promise<T>,
|
||||
options?: MatrixSendQueueOptions,
|
||||
): Promise<T> {
|
||||
const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS;
|
||||
const delayFn = options?.delayFn ?? delay;
|
||||
return roomQueues.enqueue(roomId, async () => {
|
||||
await delayFn(gapMs);
|
||||
return await fn();
|
||||
const previous = roomQueues.get(roomId) ?? Promise.resolve();
|
||||
|
||||
const next = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await delayFn(gapMs);
|
||||
return await fn();
|
||||
});
|
||||
|
||||
const queueMarker = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
roomQueues.set(roomId, queueMarker);
|
||||
|
||||
queueMarker.finally(() => {
|
||||
if (roomQueues.get(roomId) === queueMarker) {
|
||||
roomQueues.delete(roomId);
|
||||
}
|
||||
});
|
||||
|
||||
return await next;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
|
||||
@@ -164,13 +164,7 @@ const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST
|
||||
const PNG_BUFFER = Buffer.from("png");
|
||||
const PNG_BASE64 = PNG_BUFFER.toString("base64");
|
||||
const PDF_BUFFER = Buffer.from("pdf");
|
||||
const createTokenProvider = (
|
||||
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
|
||||
) => ({
|
||||
getAccessToken: vi.fn(async (scope: string) =>
|
||||
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
|
||||
),
|
||||
});
|
||||
const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") });
|
||||
const asSingleItemArray = <T>(value: T) => [value];
|
||||
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
|
||||
label,
|
||||
@@ -700,121 +694,6 @@ describe("msteams attachments", () => {
|
||||
runAttachmentAuthRetryCase,
|
||||
);
|
||||
|
||||
it("preserves auth fallback when dispatcher-mode fetch returns a redirect", async () => {
|
||||
const redirectedUrl = createTestUrl("redirected.png");
|
||||
const tokenProvider = createTokenProvider();
|
||||
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
|
||||
const hasAuth = Boolean(new Headers(opts?.headers).get("Authorization"));
|
||||
if (url === TEST_URL_IMAGE) {
|
||||
return hasAuth
|
||||
? createRedirectResponse(redirectedUrl)
|
||||
: createTextResponse("unauthorized", 401);
|
||||
}
|
||||
if (url === redirectedUrl) {
|
||||
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
|
||||
}
|
||||
return createNotFoundResponse();
|
||||
});
|
||||
|
||||
fetchRemoteMediaMock.mockImplementationOnce(async (params) => {
|
||||
const fetchFn = params.fetchImpl ?? fetch;
|
||||
let currentUrl = params.url;
|
||||
for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) {
|
||||
const res = await fetchFn(currentUrl, {
|
||||
redirect: "manual",
|
||||
dispatcher: {},
|
||||
} as RequestInit);
|
||||
if (REDIRECT_STATUS_CODES.includes(res.status)) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
throw new Error("redirect missing location");
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
return readRemoteMediaResponse(res, params);
|
||||
}
|
||||
throw new Error("too many redirects");
|
||||
});
|
||||
|
||||
const media = await downloadAttachmentsWithFetch(
|
||||
createImageAttachments(TEST_URL_IMAGE),
|
||||
fetchMock,
|
||||
{ tokenProvider, authAllowHosts: [TEST_HOST] },
|
||||
);
|
||||
|
||||
expectAttachmentMediaLength(media, 1);
|
||||
expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
|
||||
});
|
||||
|
||||
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
|
||||
let authAttempt = 0;
|
||||
const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const auth = new Headers(opts?.headers).get("Authorization");
|
||||
if (!auth) {
|
||||
return createTextResponse("unauthorized", 401);
|
||||
}
|
||||
authAttempt += 1;
|
||||
if (authAttempt === 1) {
|
||||
return createTextResponse("upstream transient", 500);
|
||||
}
|
||||
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
|
||||
});
|
||||
|
||||
const media = await downloadAttachmentsWithFetch(
|
||||
createImageAttachments(TEST_URL_IMAGE),
|
||||
fetchMock,
|
||||
{ tokenProvider, authAllowHosts: [TEST_HOST] },
|
||||
);
|
||||
|
||||
expectAttachmentMediaLength(media, 1);
|
||||
expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not forward Authorization to redirects outside auth allowlist", async () => {
|
||||
const tokenProvider = createTokenProvider("top-secret-token");
|
||||
const graphFileUrl = createUrlForHost(GRAPH_HOST, "file");
|
||||
const seen: Array<{ url: string; auth: string }> = [];
|
||||
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
|
||||
const auth = new Headers(opts?.headers).get("Authorization") ?? "";
|
||||
seen.push({ url, auth });
|
||||
if (url === graphFileUrl && !auth) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}
|
||||
if (url === graphFileUrl && auth) {
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: { location: "https://attacker.azureedge.net/collect" },
|
||||
});
|
||||
}
|
||||
if (url === "https://attacker.azureedge.net/collect") {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
headers: { "content-type": CONTENT_TYPE_IMAGE_PNG },
|
||||
});
|
||||
}
|
||||
return createNotFoundResponse();
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments(
|
||||
buildDownloadParams([{ contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: graphFileUrl }], {
|
||||
tokenProvider,
|
||||
allowHosts: [GRAPH_HOST, AZUREEDGE_HOST],
|
||||
authAllowHosts: [GRAPH_HOST],
|
||||
fetchFn: asFetchFn(fetchMock),
|
||||
}),
|
||||
);
|
||||
|
||||
expectSingleMedia(media);
|
||||
const redirected = seen.find(
|
||||
(entry) => entry.url === "https://attacker.azureedge.net/collect",
|
||||
);
|
||||
expect(redirected).toBeDefined();
|
||||
expect(redirected?.auth).toBe("");
|
||||
});
|
||||
|
||||
it("skips urls outside the allowlist", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadAttachmentsWithFetch(
|
||||
@@ -865,49 +744,6 @@ describe("msteams attachments", () => {
|
||||
describe("downloadMSTeamsGraphMedia", () => {
|
||||
it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
|
||||
|
||||
it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
|
||||
const tokenProvider = createTokenProvider("top-secret-token");
|
||||
const escapedUrl = "https://example.com/collect";
|
||||
const seen: Array<{ url: string; auth: string }> = [];
|
||||
const referenceAttachment = createReferenceAttachment();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const auth = new Headers(init?.headers).get("Authorization") ?? "";
|
||||
seen.push({ url, auth });
|
||||
|
||||
if (url === DEFAULT_MESSAGE_URL) {
|
||||
return createJsonResponse({ attachments: [referenceAttachment] });
|
||||
}
|
||||
if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
|
||||
return createGraphCollectionResponse([]);
|
||||
}
|
||||
if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
|
||||
return createGraphCollectionResponse([referenceAttachment]);
|
||||
}
|
||||
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
|
||||
return createRedirectResponse(escapedUrl);
|
||||
}
|
||||
if (url === escapedUrl) {
|
||||
return createPdfResponse();
|
||||
}
|
||||
return createNotFoundResponse();
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: DEFAULT_MESSAGE_URL,
|
||||
tokenProvider,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
|
||||
authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
|
||||
fetchFn: asFetchFn(fetchMock),
|
||||
});
|
||||
|
||||
expectAttachmentMediaLength(media.media, 1);
|
||||
const redirected = seen.find((entry) => entry.url === escapedUrl);
|
||||
expect(redirected).toBeDefined();
|
||||
expect(redirected?.auth).toBe("");
|
||||
});
|
||||
|
||||
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
|
||||
const escapedUrl = "https://evil.example/internal.pdf";
|
||||
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
@@ -6,12 +7,11 @@ import {
|
||||
isDownloadableAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveRequestUrl,
|
||||
safeFetchWithPolicy,
|
||||
resolveAuthAllowedHosts,
|
||||
resolveAllowedHosts,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
@@ -86,69 +86,22 @@ function scopeCandidatesForUrl(url: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||
}
|
||||
|
||||
async function fetchWithAuthFallback(params: {
|
||||
url: string;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
authAllowHosts: string[];
|
||||
}): Promise<Response> {
|
||||
const firstAttempt = await safeFetchWithPolicy({
|
||||
return await fetchWithBearerAuthScopeFallback({
|
||||
url: params.url,
|
||||
policy: params.policy,
|
||||
scopes: scopeCandidatesForUrl(params.url),
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: params.requestInit,
|
||||
requireHttps: true,
|
||||
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
|
||||
});
|
||||
if (firstAttempt.ok) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (!params.tokenProvider) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
const scopes = scopeCandidatesForUrl(params.url);
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const token = await params.tokenProvider.getAccessToken(scope);
|
||||
const authHeaders = new Headers(params.requestInit?.headers);
|
||||
authHeaders.set("Authorization", `Bearer ${token}`);
|
||||
const authAttempt = await safeFetchWithPolicy({
|
||||
url: params.url,
|
||||
policy: params.policy,
|
||||
fetchFn,
|
||||
requestInit: {
|
||||
...params.requestInit,
|
||||
headers: authHeaders,
|
||||
},
|
||||
});
|
||||
if (authAttempt.ok) {
|
||||
return authAttempt;
|
||||
}
|
||||
if (isRedirectStatus(authAttempt.status)) {
|
||||
// Redirects in guarded fetch mode must propagate to the outer guard.
|
||||
return authAttempt;
|
||||
}
|
||||
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
|
||||
// Preserve scope fallback semantics for non-auth failures.
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Try the next scope.
|
||||
}
|
||||
}
|
||||
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,11 +122,8 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
if (list.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const policy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const allowHosts = policy.allowHosts;
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
||||
|
||||
// Download ANY downloadable attachment (not just images)
|
||||
@@ -250,7 +200,7 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: init,
|
||||
policy,
|
||||
authAllowHosts,
|
||||
}),
|
||||
});
|
||||
out.push(media);
|
||||
|
||||
@@ -3,17 +3,14 @@ import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
GRAPH_ROOT,
|
||||
inferPlaceholder,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveRequestUrl,
|
||||
safeFetchWithPolicy,
|
||||
resolveAllowedHosts,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
@@ -244,11 +241,8 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
if (!params.messageUrl || !params.tokenProvider) {
|
||||
return { media: [] };
|
||||
}
|
||||
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
||||
const messageUrl = params.messageUrl;
|
||||
let accessToken: string;
|
||||
try {
|
||||
@@ -294,7 +288,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
try {
|
||||
// SharePoint URLs need to be accessed via Graph shares API
|
||||
const shareUrl = att.contentUrl!;
|
||||
if (!isUrlAllowed(shareUrl, policy.allowHosts)) {
|
||||
if (!isUrlAllowed(shareUrl, allowHosts)) {
|
||||
continue;
|
||||
}
|
||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
||||
@@ -310,21 +304,8 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: requestUrl,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: accessToken,
|
||||
});
|
||||
return await safeFetchWithPolicy({
|
||||
url: requestUrl,
|
||||
policy,
|
||||
fetchFn,
|
||||
requestInit: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
});
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return await fetchFn(requestUrl, { ...init, headers });
|
||||
},
|
||||
});
|
||||
sharePointMedia.push(media);
|
||||
@@ -376,8 +357,8 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
attachments: filteredAttachments,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts: policy.allowHosts,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
@@ -1,54 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
isPrivateOrReservedIP,
|
||||
isUrlAllowed,
|
||||
resolveAndValidateIP,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveAllowedHosts,
|
||||
resolveAuthAllowedHosts,
|
||||
resolveMediaSsrfPolicy,
|
||||
safeFetch,
|
||||
safeFetchWithPolicy,
|
||||
} from "./shared.js";
|
||||
|
||||
const publicResolve = async () => ({ address: "13.107.136.10" });
|
||||
const privateResolve = (ip: string) => async () => ({ address: ip });
|
||||
const failingResolve = async () => {
|
||||
throw new Error("DNS failure");
|
||||
};
|
||||
|
||||
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
|
||||
return vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const target = redirectMap[url];
|
||||
if (target && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: target },
|
||||
});
|
||||
}
|
||||
return new Response(finalBody, { status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
describe("msteams attachment allowlists", () => {
|
||||
it("normalizes wildcard host lists", () => {
|
||||
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("resolves a normalized attachment fetch policy", () => {
|
||||
expect(
|
||||
resolveAttachmentFetchPolicy({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires https and host suffix match", () => {
|
||||
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
|
||||
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
|
||||
@@ -62,317 +25,4 @@ describe("msteams attachment allowlists", () => {
|
||||
});
|
||||
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["999.999.999.999", true],
|
||||
["256.0.0.1", true],
|
||||
["10.0.0.256", true],
|
||||
["-1.0.0.1", false],
|
||||
["1.2.3.4.5", false],
|
||||
["0:0:0:0:0:0:0:1", true],
|
||||
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
|
||||
expect(isPrivateOrReservedIP(ip)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveAndValidateIP", () => {
|
||||
it("accepts a hostname resolving to a public IP", async () => {
|
||||
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
|
||||
expect(ip).toBe("13.107.136.10");
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to 10.x.x.x", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to 169.254.169.254", async () => {
|
||||
await expect(
|
||||
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
|
||||
).rejects.toThrow("private/reserved IP");
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to loopback", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to IPv6 loopback", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on DNS resolution failure", async () => {
|
||||
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
|
||||
"DNS resolution failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── safeFetch ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("safeFetch", () => {
|
||||
it("fetches a URL directly when no redirect occurs", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const res = await safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
// Should have used redirect: "manual"
|
||||
expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
|
||||
});
|
||||
|
||||
it("follows a redirect to an allowlisted host with public IP", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
|
||||
});
|
||||
const res = await safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
|
||||
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": redirectedTo,
|
||||
});
|
||||
const res = await safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { dispatcher: {} } as RequestInit,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe(redirectedTo);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { dispatcher: {} } as RequestInit,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("blocked by allowlist");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks a redirect to a non-allowlisted host", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("blocked by allowlist");
|
||||
// Should not have fetched the evil URL
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
|
||||
let callCount = 0;
|
||||
const rebindingResolve = async () => {
|
||||
callCount++;
|
||||
// First call (initial URL) resolves to public IP
|
||||
if (callCount === 1) return { address: "13.107.136.10" };
|
||||
// Second call (redirect target) resolves to private IP
|
||||
return { address: "169.254.169.254" };
|
||||
};
|
||||
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com", "trafficmanager.net"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: rebindingResolve,
|
||||
}),
|
||||
).rejects.toThrow("private/reserved IP");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks when the initial URL resolves to a private IP", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://evil.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: privateResolve("10.0.0.1"),
|
||||
}),
|
||||
).rejects.toThrow("Initial download URL blocked");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks when initial URL DNS resolution fails", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://nonexistent.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: failingResolve,
|
||||
}),
|
||||
).rejects.toThrow("Initial download URL blocked");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows multiple redirects when all are valid", async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://b.sharepoint.com/2" },
|
||||
});
|
||||
}
|
||||
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://c.sharepoint.com/3" },
|
||||
});
|
||||
}
|
||||
return new Response("final", { status: 200 });
|
||||
});
|
||||
|
||||
const res = await safeFetch({
|
||||
url: "https://a.sharepoint.com/1",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws on too many redirects", async () => {
|
||||
let counter = 0;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.redirect === "manual") {
|
||||
counter++;
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: `https://loop${counter}.sharepoint.com/x` },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://start.sharepoint.com/x",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("Too many redirects");
|
||||
});
|
||||
|
||||
it("blocks redirect to HTTP (non-HTTPS)", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("blocked by allowlist");
|
||||
});
|
||||
|
||||
it("strips authorization across redirects outside auth allowlist", async () => {
|
||||
const seenAuth: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const auth = new Headers(init?.headers).get("authorization") ?? "";
|
||||
seenAuth.push(`${url}|${auth}`);
|
||||
if (url === "https://teams.sharepoint.com/file.pdf") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const headers = new Headers({ Authorization: "Bearer secret" });
|
||||
const res = await safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authorizationAllowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { headers },
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(seenAuth[0]).toContain("Bearer secret");
|
||||
expect(seenAuth[1]).toMatch(/\|$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment fetch auth helpers", () => {
|
||||
it("sets and clears authorization header by auth allowlist", () => {
|
||||
const headers = new Headers();
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: "https://graph.microsoft.com/v1.0/me",
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
expect(headers.get("authorization")).toBe("Bearer token-1");
|
||||
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: "https://evil.example.com/collect",
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
expect(headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("safeFetchWithPolicy forwards policy allowlists", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const res = await safeFetchWithPolicy({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
policy: resolveAttachmentFetchPolicy({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
}),
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
isPrivateIpAddress,
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
@@ -266,194 +264,10 @@ export function resolveAuthAllowedHosts(input?: string[]): string[] {
|
||||
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
|
||||
}
|
||||
|
||||
export type MSTeamsAttachmentFetchPolicy = {
|
||||
allowHosts: string[];
|
||||
authAllowHosts: string[];
|
||||
};
|
||||
|
||||
export function resolveAttachmentFetchPolicy(params?: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
}): MSTeamsAttachmentFetchPolicy {
|
||||
return {
|
||||
allowHosts: resolveAllowedHosts(params?.allowHosts),
|
||||
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
|
||||
};
|
||||
}
|
||||
|
||||
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
|
||||
}
|
||||
|
||||
export function applyAuthorizationHeaderForUrl(params: {
|
||||
headers: Headers;
|
||||
url: string;
|
||||
authAllowHosts: string[];
|
||||
bearerToken?: string;
|
||||
}): void {
|
||||
if (!params.bearerToken) {
|
||||
params.headers.delete("Authorization");
|
||||
return;
|
||||
}
|
||||
if (isUrlAllowed(params.url, params.authAllowHosts)) {
|
||||
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
|
||||
return;
|
||||
}
|
||||
params.headers.delete("Authorization");
|
||||
}
|
||||
|
||||
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
|
||||
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
|
||||
* or link-local range that must never be reached from media downloads.
|
||||
*
|
||||
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
|
||||
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
|
||||
* parse errors.
|
||||
*/
|
||||
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
|
||||
|
||||
/**
|
||||
* Resolve a hostname via DNS and reject private/reserved IPs.
|
||||
* Throws if the resolved IP is private or resolution fails.
|
||||
*/
|
||||
export async function resolveAndValidateIP(
|
||||
hostname: string,
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>,
|
||||
): Promise<string> {
|
||||
const resolve = resolveFn ?? lookup;
|
||||
let resolved: { address: string };
|
||||
try {
|
||||
resolved = await resolve(hostname);
|
||||
} catch {
|
||||
throw new Error(`DNS resolution failed for "${hostname}"`);
|
||||
}
|
||||
if (isPrivateOrReservedIP(resolved.address)) {
|
||||
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
|
||||
}
|
||||
return resolved.address;
|
||||
}
|
||||
|
||||
/** Maximum number of redirects to follow in safeFetch. */
|
||||
const MAX_SAFE_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* Fetch a URL with redirect: "manual", validating each redirect target
|
||||
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
|
||||
*
|
||||
* This prevents:
|
||||
* - Auto-following redirects to non-allowlisted hosts
|
||||
* - DNS rebinding attacks when a lookup function is provided
|
||||
*/
|
||||
export async function safeFetch(params: {
|
||||
url: string;
|
||||
allowHosts: string[];
|
||||
/**
|
||||
* Optional allowlist for forwarding Authorization across redirects.
|
||||
* When set, Authorization is stripped before following redirects to hosts
|
||||
* outside this list.
|
||||
*/
|
||||
authorizationAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
||||
}): Promise<Response> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const resolveFn = params.resolveFn;
|
||||
const hasDispatcher = Boolean(
|
||||
params.requestInit &&
|
||||
typeof params.requestInit === "object" &&
|
||||
"dispatcher" in (params.requestInit as Record<string, unknown>),
|
||||
);
|
||||
const currentHeaders = new Headers(params.requestInit?.headers);
|
||||
let currentUrl = params.url;
|
||||
|
||||
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
|
||||
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
||||
}
|
||||
|
||||
if (resolveFn) {
|
||||
try {
|
||||
const initialHost = new URL(currentUrl).hostname;
|
||||
await resolveAndValidateIP(initialHost, resolveFn);
|
||||
} catch {
|
||||
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
|
||||
const res = await fetchFn(currentUrl, {
|
||||
...params.requestInit,
|
||||
headers: currentHeaders,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
return res;
|
||||
}
|
||||
|
||||
let redirectUrl: string;
|
||||
try {
|
||||
redirectUrl = new URL(location, currentUrl).toString();
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect URL: ${location}`);
|
||||
}
|
||||
|
||||
// Validate redirect target against hostname allowlist
|
||||
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
|
||||
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
|
||||
}
|
||||
|
||||
// Prevent credential bleed: only keep Authorization on redirect hops that
|
||||
// are explicitly auth-allowlisted.
|
||||
if (
|
||||
currentHeaders.has("authorization") &&
|
||||
params.authorizationAllowHosts &&
|
||||
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
|
||||
) {
|
||||
currentHeaders.delete("authorization");
|
||||
}
|
||||
|
||||
// When a pinned dispatcher is already injected by an upstream guard
|
||||
// (for example fetchWithSsrFGuard), let that guard own redirect handling
|
||||
// after this allowlist validation step.
|
||||
if (hasDispatcher) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Validate redirect target's resolved IP
|
||||
if (resolveFn) {
|
||||
const redirectHost = new URL(redirectUrl).hostname;
|
||||
await resolveAndValidateIP(redirectHost, resolveFn);
|
||||
}
|
||||
|
||||
currentUrl = redirectUrl;
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
|
||||
}
|
||||
|
||||
export async function safeFetchWithPolicy(params: {
|
||||
url: string;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
||||
}): Promise<Response> {
|
||||
return await safeFetch({
|
||||
url: params.url,
|
||||
allowHosts: params.policy.allowHosts,
|
||||
authorizationAllowHosts: params.policy.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
classifyMSTeamsSendError,
|
||||
formatMSTeamsSendErrorHint,
|
||||
formatUnknownError,
|
||||
isRevokedProxyError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("msteams errors", () => {
|
||||
@@ -43,28 +42,4 @@ describe("msteams errors", () => {
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
|
||||
});
|
||||
|
||||
describe("isRevokedProxyError", () => {
|
||||
it("returns true for revoked proxy TypeError", () => {
|
||||
expect(
|
||||
isRevokedProxyError(new TypeError("Cannot perform 'set' on a proxy that has been revoked")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRevokedProxyError(new TypeError("Cannot perform 'get' on a proxy that has been revoked")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-TypeError errors", () => {
|
||||
expect(isRevokedProxyError(new Error("proxy that has been revoked"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unrelated TypeErrors", () => {
|
||||
expect(isRevokedProxyError(new TypeError("undefined is not a function"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-error values", () => {
|
||||
expect(isRevokedProxyError(null)).toBe(false);
|
||||
expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,21 +174,6 @@ export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassifi
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an error is caused by a revoked Proxy.
|
||||
*
|
||||
* The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
|
||||
* turn handler returns. Any later access (e.g. from a debounced callback)
|
||||
* throws a TypeError whose message contains the distinctive "proxy that has
|
||||
* been revoked" string.
|
||||
*/
|
||||
export function isRevokedProxyError(err: unknown): boolean {
|
||||
if (!(err instanceof TypeError)) {
|
||||
return false;
|
||||
}
|
||||
return /proxy that has been revoked/i.test(err.message);
|
||||
}
|
||||
|
||||
export function formatMSTeamsSendErrorHint(
|
||||
classification: MSTeamsSendErrorClassification,
|
||||
): string | undefined {
|
||||
|
||||
@@ -291,79 +291,6 @@ describe("msteams messenger", () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it("falls back to proactive messaging when thread context is revoked", async () => {
|
||||
const proactiveSent: string[] = [];
|
||||
|
||||
const ctx = {
|
||||
sendActivity: async () => {
|
||||
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
// Should have fallen back to proactive messaging
|
||||
expect(proactiveSent).toEqual(["hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
});
|
||||
|
||||
it("falls back only for remaining thread messages after context revocation", async () => {
|
||||
const threadSent: string[] = [];
|
||||
const proactiveSent: string[] = [];
|
||||
let attempt = 0;
|
||||
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
const content = text ?? "";
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
threadSent.push(content);
|
||||
return { id: `id:${content}` };
|
||||
}
|
||||
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
|
||||
},
|
||||
};
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "one" }, { text: "two" }, { text: "three" }],
|
||||
});
|
||||
|
||||
expect(threadSent).toEqual(["one"]);
|
||||
expect(proactiveSent).toEqual(["two", "three"]);
|
||||
expect(ids).toEqual(["id:one", "id:two", "id:three"]);
|
||||
});
|
||||
|
||||
it("retries top-level sends on transient (5xx)", async () => {
|
||||
const attempts: string[] = [];
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
import { parseMentions } from "./mentions.js";
|
||||
import { withRevokedProxyFallback } from "./revoked-context.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
@@ -442,83 +441,44 @@ export async function sendMSTeamsMessages(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessageInContext = async (
|
||||
ctx: SendContext,
|
||||
message: MSTeamsRenderedMessage,
|
||||
messageIndex: number,
|
||||
): Promise<string> => {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(
|
||||
message,
|
||||
params.conversationRef,
|
||||
params.tokenProvider,
|
||||
params.sharePointSiteId,
|
||||
params.mediaMaxBytes,
|
||||
const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(
|
||||
message,
|
||||
params.conversationRef,
|
||||
params.tokenProvider,
|
||||
params.sharePointSiteId,
|
||||
params.mediaMaxBytes,
|
||||
),
|
||||
),
|
||||
),
|
||||
{ messageIndex, messageCount: messages.length },
|
||||
);
|
||||
return extractMessageId(response) ?? "unknown";
|
||||
};
|
||||
|
||||
const sendMessageBatchInContext = async (
|
||||
ctx: SendContext,
|
||||
batch: MSTeamsRenderedMessage[],
|
||||
startIndex: number,
|
||||
): Promise<string[]> => {
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of batch.entries()) {
|
||||
messageIds.push(await sendMessageInContext(ctx, message, startIndex + idx));
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
}
|
||||
return messageIds;
|
||||
};
|
||||
|
||||
const sendProactively = async (
|
||||
batch: MSTeamsRenderedMessage[],
|
||||
startIndex: number,
|
||||
): Promise<string[]> => {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const proactiveRef: MSTeamsConversationReference = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
const messageIds: string[] = [];
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
messageIds.push(...(await sendMessageBatchInContext(ctx, batch, startIndex)));
|
||||
});
|
||||
return messageIds;
|
||||
};
|
||||
|
||||
if (params.replyStyle === "thread") {
|
||||
const ctx = params.context;
|
||||
if (!ctx) {
|
||||
throw new Error("Missing context for replyStyle=thread");
|
||||
}
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const result = await withRevokedProxyFallback({
|
||||
run: async () => ({
|
||||
ids: [await sendMessageInContext(ctx, message, idx)],
|
||||
fellBack: false,
|
||||
}),
|
||||
onRevoked: async () => {
|
||||
const remaining = messages.slice(idx);
|
||||
return {
|
||||
ids: remaining.length > 0 ? await sendProactively(remaining, idx) : [],
|
||||
fellBack: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
messageIds.push(...result.ids);
|
||||
if (result.fellBack) {
|
||||
return messageIds;
|
||||
}
|
||||
}
|
||||
return messageIds;
|
||||
return await sendMessagesInContext(ctx);
|
||||
}
|
||||
|
||||
return await sendProactively(messages, 0);
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const proactiveRef: MSTeamsConversationReference = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
const messageIds: string[] = [];
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
messageIds.push(...(await sendMessagesInContext(ctx)));
|
||||
});
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,10 @@ describe("msteams file consent invoke authz", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
// Wait for async upload to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -189,9 +192,12 @@ describe("msteams file consent invoke authz", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
"The file upload request has expired. Please try sending the file again.",
|
||||
);
|
||||
// Wait for async handler to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
"The file upload request has expired. Please try sending the file again.",
|
||||
);
|
||||
});
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
|
||||
expect(getPendingUpload(uploadId)).toBeDefined();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.j
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import { withRevokedProxyFallback } from "./revoked-context.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
export type MSTeamsAccessTokenProvider = {
|
||||
@@ -147,19 +146,10 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
// Send invoke response IMMEDIATELY to prevent Teams timeout
|
||||
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
|
||||
|
||||
try {
|
||||
await withRevokedProxyFallback({
|
||||
run: async () => await handleFileConsentInvoke(ctx, deps.log),
|
||||
onRevoked: async () => true,
|
||||
onRevokedLog: () => {
|
||||
deps.log.debug?.(
|
||||
"turn context revoked during file consent invoke; skipping delayed response",
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle file upload asynchronously (don't await)
|
||||
handleFileConsentInvoke(ctx, deps.log).catch((err) => {
|
||||
deps.log.debug?.("file consent handler error", { error: String(err) });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
return originalRun.call(handler, context);
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
|
||||
type FakeServer = EventEmitter & {
|
||||
close: (callback?: (err?: Error | null) => void) => void;
|
||||
setTimeout: (msecs: number) => FakeServer;
|
||||
requestTimeout: number;
|
||||
headersTimeout: number;
|
||||
};
|
||||
|
||||
const expressControl = vi.hoisted(() => ({
|
||||
mode: { value: "listening" as "listening" | "error" },
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
|
||||
keepHttpServerTaskAlive: vi.fn(
|
||||
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (params.abortSignal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
params.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
await params.onAbort?.();
|
||||
},
|
||||
),
|
||||
mergeAllowlist: (params: { existing?: string[]; additions?: string[] }) =>
|
||||
Array.from(new Set([...(params.existing ?? []), ...(params.additions ?? [])])),
|
||||
summarizeMapping: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("express", () => {
|
||||
const json = vi.fn(() => {
|
||||
return (_req: unknown, _res: unknown, next?: (err?: unknown) => void) => {
|
||||
next?.();
|
||||
};
|
||||
});
|
||||
|
||||
const factory = () => ({
|
||||
use: vi.fn(),
|
||||
post: vi.fn(),
|
||||
listen: vi.fn((_port: number) => {
|
||||
const server = new EventEmitter() as FakeServer;
|
||||
server.setTimeout = vi.fn((_msecs: number) => server);
|
||||
server.requestTimeout = 0;
|
||||
server.headersTimeout = 0;
|
||||
server.close = (callback?: (err?: Error | null) => void) => {
|
||||
queueMicrotask(() => {
|
||||
server.emit("close");
|
||||
callback?.(null);
|
||||
});
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
if (expressControl.mode.value === "error") {
|
||||
server.emit("error", new Error("listen EADDRINUSE"));
|
||||
return;
|
||||
}
|
||||
server.emit("listening");
|
||||
});
|
||||
return server;
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
default: factory,
|
||||
json,
|
||||
};
|
||||
});
|
||||
|
||||
const registerMSTeamsHandlers = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
run: vi.fn(async () => {}),
|
||||
})),
|
||||
);
|
||||
const createMSTeamsAdapter = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
process: vi.fn(async () => {}),
|
||||
})),
|
||||
);
|
||||
const loadMSTeamsSdkWithAuth = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
sdk: {
|
||||
ActivityHandler: class {},
|
||||
MsalTokenProvider: class {},
|
||||
authorizeJWT:
|
||||
() => (_req: unknown, _res: unknown, next: ((err?: unknown) => void) | undefined) =>
|
||||
next?.(),
|
||||
},
|
||||
authConfig: {},
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("./monitor-handler.js", () => ({
|
||||
registerMSTeamsHandlers: () => registerMSTeamsHandlers(),
|
||||
}));
|
||||
|
||||
vi.mock("./resolve-allowlist.js", () => ({
|
||||
resolveMSTeamsChannelAllowlist: vi.fn(async () => []),
|
||||
resolveMSTeamsUserAllowlist: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("./sdk.js", () => ({
|
||||
createMSTeamsAdapter: () => createMSTeamsAdapter(),
|
||||
loadMSTeamsSdkWithAuth: () => loadMSTeamsSdkWithAuth(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMSTeamsRuntime: () => ({
|
||||
logging: {
|
||||
getChildLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { monitorMSTeamsProvider } from "./monitor.js";
|
||||
|
||||
function createConfig(port: number): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
appId: "app-id",
|
||||
appPassword: "app-password",
|
||||
tenantId: "tenant-id",
|
||||
webhook: {
|
||||
port,
|
||||
path: "/api/messages",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createStores() {
|
||||
return {
|
||||
conversationStore: {} as MSTeamsConversationStore,
|
||||
pollStore: {} as MSTeamsPollStore,
|
||||
};
|
||||
}
|
||||
|
||||
describe("monitorMSTeamsProvider lifecycle", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
expressControl.mode.value = "listening";
|
||||
});
|
||||
|
||||
it("stays active until aborted", async () => {
|
||||
const abort = new AbortController();
|
||||
const stores = createStores();
|
||||
const task = monitorMSTeamsProvider({
|
||||
cfg: createConfig(0),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: abort.signal,
|
||||
conversationStore: stores.conversationStore,
|
||||
pollStore: stores.pollStore,
|
||||
});
|
||||
|
||||
const early = await Promise.race([
|
||||
task.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)),
|
||||
]);
|
||||
expect(early).toBe("pending");
|
||||
|
||||
abort.abort();
|
||||
await expect(task).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
shutdown: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects startup when webhook port is already in use", async () => {
|
||||
expressControl.mode.value = "error";
|
||||
await expect(
|
||||
monitorMSTeamsProvider({
|
||||
cfg: createConfig(3978),
|
||||
runtime: createRuntime(),
|
||||
abortSignal: new AbortController().signal,
|
||||
conversationStore: createStores().conversationStore,
|
||||
pollStore: createStores().pollStore,
|
||||
}),
|
||||
).rejects.toThrow(/EADDRINUSE/);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
import { createConnection, type AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMSTeamsWebhookTimeouts } from "./monitor.js";
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSlowBodySocketClose(port: number, timeoutMs: number): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const socket = createConnection({ host: "127.0.0.1", port }, () => {
|
||||
socket.write("POST /api/messages HTTP/1.1\r\n");
|
||||
socket.write("Host: localhost\r\n");
|
||||
socket.write("Content-Type: application/json\r\n");
|
||||
socket.write("Content-Length: 1048576\r\n");
|
||||
socket.write("\r\n");
|
||||
socket.write('{"type":"message"');
|
||||
});
|
||||
socket.on("error", () => {
|
||||
// ECONNRESET is expected once the server drops the socket.
|
||||
});
|
||||
const failTimer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`socket stayed open for ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
socket.on("close", () => {
|
||||
clearTimeout(failTimer);
|
||||
resolve(Date.now() - startedAt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("msteams monitor webhook hardening", () => {
|
||||
it("applies explicit webhook timeout values", async () => {
|
||||
const app = express();
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
try {
|
||||
applyMSTeamsWebhookTimeouts(server, {
|
||||
inactivityTimeoutMs: 3210,
|
||||
requestTimeoutMs: 6543,
|
||||
headersTimeoutMs: 9876,
|
||||
});
|
||||
|
||||
expect(server.timeout).toBe(3210);
|
||||
expect(server.requestTimeout).toBe(6543);
|
||||
expect(server.headersTimeout).toBe(6543);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops slow-body webhook requests within configured inactivity timeout", async () => {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, _next) => {
|
||||
res.status(401).end("unauthorized");
|
||||
});
|
||||
app.post("/api/messages", (_req, res) => {
|
||||
res.end("ok");
|
||||
});
|
||||
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
try {
|
||||
applyMSTeamsWebhookTimeouts(server, {
|
||||
inactivityTimeoutMs: 400,
|
||||
requestTimeoutMs: 1500,
|
||||
headersTimeoutMs: 1500,
|
||||
});
|
||||
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const closedMs = await waitForSlowBodySocketClose(port, 3000);
|
||||
expect(closedMs).toBeLessThan(2500);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES,
|
||||
keepHttpServerTaskAlive,
|
||||
mergeAllowlist,
|
||||
summarizeMapping,
|
||||
type OpenClawConfig,
|
||||
@@ -36,31 +34,6 @@ export type MonitorMSTeamsResult = {
|
||||
};
|
||||
|
||||
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
||||
const MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type ApplyMSTeamsWebhookTimeoutsOpts = {
|
||||
inactivityTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
headersTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export function applyMSTeamsWebhookTimeouts(
|
||||
httpServer: Server,
|
||||
opts?: ApplyMSTeamsWebhookTimeoutsOpts,
|
||||
): void {
|
||||
const inactivityTimeoutMs = opts?.inactivityTimeoutMs ?? MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS;
|
||||
const requestTimeoutMs = opts?.requestTimeoutMs ?? MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS;
|
||||
const headersTimeoutMs = Math.min(
|
||||
opts?.headersTimeoutMs ?? MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS,
|
||||
requestTimeoutMs,
|
||||
);
|
||||
|
||||
httpServer.setTimeout(inactivityTimeoutMs);
|
||||
httpServer.requestTimeout = requestTimeoutMs;
|
||||
httpServer.headersTimeout = headersTimeoutMs;
|
||||
}
|
||||
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
@@ -300,23 +273,10 @@ export async function monitorMSTeamsProvider(
|
||||
fallback: "/api/messages",
|
||||
});
|
||||
|
||||
// Start listening and fail fast if bind/listen fails.
|
||||
const httpServer = expressApp.listen(port);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
log.info(`msteams provider started on port ${port}`);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
httpServer.off("listening", onListening);
|
||||
log.error("msteams server error", { error: String(err) });
|
||||
reject(err);
|
||||
};
|
||||
httpServer.once("listening", onListening);
|
||||
httpServer.once("error", onError);
|
||||
// Start listening and capture the HTTP server handle
|
||||
const httpServer = expressApp.listen(port, () => {
|
||||
log.info(`msteams provider started on port ${port}`);
|
||||
});
|
||||
applyMSTeamsWebhookTimeouts(httpServer);
|
||||
|
||||
httpServer.on("error", (err) => {
|
||||
log.error("msteams server error", { error: String(err) });
|
||||
@@ -334,12 +294,12 @@ export async function monitorMSTeamsProvider(
|
||||
});
|
||||
};
|
||||
|
||||
// Keep this task alive until close so gateway runtime does not treat startup as exit.
|
||||
await keepHttpServerTaskAlive({
|
||||
server: httpServer,
|
||||
abortSignal: opts.abortSignal,
|
||||
onAbort: shutdown,
|
||||
});
|
||||
// Handle abort signal
|
||||
if (opts.abortSignal) {
|
||||
opts.abortSignal.addEventListener("abort", () => {
|
||||
void shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
return { app: expressApp, shutdown };
|
||||
}
|
||||
|
||||
@@ -15,13 +15,11 @@ import {
|
||||
formatUnknownError,
|
||||
} from "./errors.js";
|
||||
import {
|
||||
buildConversationReference,
|
||||
type MSTeamsAdapter,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { withRevokedProxyFallback } from "./revoked-context.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
@@ -44,35 +42,9 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
sharePointSiteId?: string;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
|
||||
/**
|
||||
* Send a typing indicator.
|
||||
*
|
||||
* First tries the live turn context (cheapest path). When the context has
|
||||
* been revoked (debounced messages) we fall back to proactive messaging via
|
||||
* the stored conversation reference so the user still sees the "…" bubble.
|
||||
*/
|
||||
const sendTypingIndicator = async () => {
|
||||
await withRevokedProxyFallback({
|
||||
run: async () => {
|
||||
await params.context.sendActivity({ type: "typing" });
|
||||
},
|
||||
onRevoked: async () => {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
await params.adapter.continueConversation(
|
||||
params.appId,
|
||||
{ ...baseRef, activityId: undefined },
|
||||
async (ctx) => {
|
||||
await ctx.sendActivity({ type: "typing" });
|
||||
},
|
||||
);
|
||||
},
|
||||
onRevokedLog: () => {
|
||||
params.log.debug?.("turn context revoked, sending typing via proactive messaging");
|
||||
},
|
||||
});
|
||||
await params.context.sendActivity({ type: "typing" });
|
||||
};
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: sendTypingIndicator,
|
||||
onStartError: (err) => {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withRevokedProxyFallback } from "./revoked-context.js";
|
||||
|
||||
describe("msteams revoked context helper", () => {
|
||||
it("returns primary result when no error occurs", async () => {
|
||||
await expect(
|
||||
withRevokedProxyFallback({
|
||||
run: async () => "ok",
|
||||
onRevoked: async () => "fallback",
|
||||
}),
|
||||
).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("uses fallback when proxy-revoked TypeError is thrown", async () => {
|
||||
const onRevokedLog = vi.fn();
|
||||
await expect(
|
||||
withRevokedProxyFallback({
|
||||
run: async () => {
|
||||
throw new TypeError("Cannot perform 'get' on a proxy that has been revoked");
|
||||
},
|
||||
onRevoked: async () => "fallback",
|
||||
onRevokedLog,
|
||||
}),
|
||||
).resolves.toBe("fallback");
|
||||
expect(onRevokedLog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rethrows non-revoked errors", async () => {
|
||||
const err = Object.assign(new Error("boom"), { statusCode: 500 });
|
||||
await expect(
|
||||
withRevokedProxyFallback({
|
||||
run: async () => {
|
||||
throw err;
|
||||
},
|
||||
onRevoked: async () => "fallback",
|
||||
}),
|
||||
).rejects.toBe(err);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { isRevokedProxyError } from "./errors.js";
|
||||
|
||||
export async function withRevokedProxyFallback<T>(params: {
|
||||
run: () => Promise<T>;
|
||||
onRevoked: () => Promise<T>;
|
||||
onRevokedLog?: () => void;
|
||||
}): Promise<T> {
|
||||
try {
|
||||
return await params.run();
|
||||
} catch (err) {
|
||||
if (!isRevokedProxyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
params.onRevokedLog?.();
|
||||
return await params.onRevoked();
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,17 @@ type RegisteredRoute = {
|
||||
const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => vi.fn());
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} });
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
||||
registerPluginHttpRoute: registerPluginHttpRouteMock,
|
||||
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
||||
createFixedWindowRateLimiter: vi.fn(() => ({
|
||||
isRateLimited: vi.fn(() => false),
|
||||
size: vi.fn(() => 0),
|
||||
clear: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getSynologyRuntime: vi.fn(() => ({
|
||||
@@ -44,6 +40,7 @@ vi.mock("./client.js", () => ({
|
||||
}));
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
|
||||
describe("Synology channel wiring integration", () => {
|
||||
beforeEach(() => {
|
||||
registerPluginHttpRouteMock.mockClear();
|
||||
@@ -52,7 +49,6 @@ describe("Synology channel wiring integration", () => {
|
||||
|
||||
it("registers real webhook handler with resolved account config and enforces allowlist", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const abortController = new AbortController();
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
@@ -73,10 +69,9 @@ describe("Synology channel wiring integration", () => {
|
||||
},
|
||||
accountId: "alerts",
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
|
||||
const started = plugin.gateway.startAccount(ctx);
|
||||
const started = await plugin.gateway.startAccount(ctx);
|
||||
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const firstCall = registerPluginHttpRouteMock.mock.calls[0];
|
||||
@@ -102,7 +97,7 @@ describe("Synology channel wiring integration", () => {
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toContain("not authorized");
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
abortController.abort();
|
||||
await started;
|
||||
|
||||
started.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,10 +268,18 @@ describe("createSynologyChatPlugin", () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
await expect(
|
||||
plugin.outbound.sendText({
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "",
|
||||
nasHost: "h",
|
||||
webhookPath: "/w",
|
||||
dmPolicy: "open",
|
||||
allowedUserIds: [],
|
||||
rateLimitPerMinute: 30,
|
||||
botName: "Bot",
|
||||
allowInsecureSsl: true,
|
||||
},
|
||||
text: "hello",
|
||||
to: "user1",
|
||||
@@ -282,15 +290,18 @@ describe("createSynologyChatPlugin", () => {
|
||||
it("sendText returns OutboundDeliveryResult on success", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const result = await plugin.outbound.sendText({
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "https://nas/incoming",
|
||||
allowInsecureSsl: true,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "https://nas/incoming",
|
||||
nasHost: "h",
|
||||
webhookPath: "/w",
|
||||
dmPolicy: "open",
|
||||
allowedUserIds: [],
|
||||
rateLimitPerMinute: 30,
|
||||
botName: "Bot",
|
||||
allowInsecureSsl: true,
|
||||
},
|
||||
text: "hello",
|
||||
to: "user1",
|
||||
@@ -304,10 +315,18 @@ describe("createSynologyChatPlugin", () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
await expect(
|
||||
plugin.outbound.sendMedia({
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "t",
|
||||
incomingUrl: "",
|
||||
nasHost: "h",
|
||||
webhookPath: "/w",
|
||||
dmPolicy: "open",
|
||||
allowedUserIds: [],
|
||||
rateLimitPerMinute: 30,
|
||||
botName: "Bot",
|
||||
allowInsecureSsl: true,
|
||||
},
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
to: "user1",
|
||||
@@ -317,56 +336,35 @@ describe("createSynologyChatPlugin", () => {
|
||||
});
|
||||
|
||||
describe("gateway", () => {
|
||||
it("startAccount returns pending promise for disabled account", async () => {
|
||||
it("startAccount returns stop function for disabled account", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const abortController = new AbortController();
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: { "synology-chat": { enabled: false } },
|
||||
},
|
||||
accountId: "default",
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
const result = plugin.gateway.startAccount(ctx);
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
// Promise should stay pending (never resolve) to prevent restart loop
|
||||
const resolved = await Promise.race([
|
||||
result,
|
||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
||||
]);
|
||||
expect(resolved).toBe("pending");
|
||||
abortController.abort();
|
||||
await result;
|
||||
const result = await plugin.gateway.startAccount(ctx);
|
||||
expect(typeof result.stop).toBe("function");
|
||||
});
|
||||
|
||||
it("startAccount returns pending promise for account without token", async () => {
|
||||
it("startAccount returns stop function for account without token", async () => {
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const abortController = new AbortController();
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: { "synology-chat": { enabled: true } },
|
||||
},
|
||||
accountId: "default",
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
const result = plugin.gateway.startAccount(ctx);
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
// Promise should stay pending (never resolve) to prevent restart loop
|
||||
const resolved = await Promise.race([
|
||||
result,
|
||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
||||
]);
|
||||
expect(resolved).toBe("pending");
|
||||
abortController.abort();
|
||||
await result;
|
||||
const result = await plugin.gateway.startAccount(ctx);
|
||||
expect(typeof result.stop).toBe("function");
|
||||
});
|
||||
|
||||
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
||||
const registerMock = vi.mocked(registerPluginHttpRoute);
|
||||
registerMock.mockClear();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const ctx = {
|
||||
@@ -383,20 +381,12 @@ describe("createSynologyChatPlugin", () => {
|
||||
},
|
||||
accountId: "default",
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
|
||||
const result = plugin.gateway.startAccount(ctx);
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
const resolved = await Promise.race([
|
||||
result,
|
||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
||||
]);
|
||||
expect(resolved).toBe("pending");
|
||||
const result = await plugin.gateway.startAccount(ctx);
|
||||
expect(typeof result.stop).toBe("function");
|
||||
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
|
||||
expect(registerMock).not.toHaveBeenCalled();
|
||||
abortController.abort();
|
||||
await result;
|
||||
});
|
||||
|
||||
it("deregisters stale route before re-registering same account/path", async () => {
|
||||
@@ -406,9 +396,7 @@ describe("createSynologyChatPlugin", () => {
|
||||
registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
|
||||
|
||||
const plugin = createSynologyChatPlugin();
|
||||
const abortFirst = new AbortController();
|
||||
const abortSecond = new AbortController();
|
||||
const makeCtx = (abortCtrl: AbortController) => ({
|
||||
const ctx = {
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": {
|
||||
@@ -423,25 +411,18 @@ describe("createSynologyChatPlugin", () => {
|
||||
},
|
||||
accountId: "default",
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
abortSignal: abortCtrl.signal,
|
||||
});
|
||||
};
|
||||
|
||||
// Start first account (returns a pending promise)
|
||||
const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
|
||||
// Start second account on same path — should deregister the first route
|
||||
const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
|
||||
|
||||
// Give microtasks time to settle
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const first = await plugin.gateway.startAccount(ctx);
|
||||
const second = await plugin.gateway.startAccount(ctx);
|
||||
|
||||
expect(registerMock).toHaveBeenCalledTimes(2);
|
||||
expect(unregisterFirst).toHaveBeenCalledTimes(1);
|
||||
expect(unregisterSecond).not.toHaveBeenCalled();
|
||||
|
||||
// Clean up: abort both to resolve promises and prevent test leak
|
||||
abortFirst.abort();
|
||||
abortSecond.abort();
|
||||
await Promise.allSettled([firstPromise, secondPromise]);
|
||||
// Clean up active route map so this module-level state doesn't leak across tests.
|
||||
first.stop();
|
||||
second.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,23 +22,6 @@ const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrou
|
||||
|
||||
const activeRouteUnregisters = new Map<string, () => void>();
|
||||
|
||||
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const complete = () => {
|
||||
onAbort?.();
|
||||
resolve();
|
||||
};
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", complete, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function createSynologyChatPlugin() {
|
||||
return {
|
||||
id: CHANNEL_ID,
|
||||
@@ -195,8 +178,8 @@ export function createSynologyChatPlugin() {
|
||||
deliveryMode: "gateway" as const,
|
||||
textChunkLimit: 2000,
|
||||
|
||||
sendText: async ({ to, text, accountId, cfg }: any) => {
|
||||
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
|
||||
sendText: async ({ to, text, accountId, account: ctxAccount }: any) => {
|
||||
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
|
||||
|
||||
if (!account.incomingUrl) {
|
||||
throw new Error("Synology Chat incoming URL not configured");
|
||||
@@ -209,8 +192,8 @@ export function createSynologyChatPlugin() {
|
||||
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
|
||||
},
|
||||
|
||||
sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
|
||||
const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, accountId);
|
||||
sendMedia: async ({ to, mediaUrl, accountId, account: ctxAccount }: any) => {
|
||||
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
|
||||
|
||||
if (!account.incomingUrl) {
|
||||
throw new Error("Synology Chat incoming URL not configured");
|
||||
@@ -234,20 +217,20 @@ export function createSynologyChatPlugin() {
|
||||
|
||||
if (!account.enabled) {
|
||||
log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
|
||||
return waitUntilAbort(ctx.abortSignal);
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
if (!account.token || !account.incomingUrl) {
|
||||
log?.warn?.(
|
||||
`Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`,
|
||||
);
|
||||
return waitUntilAbort(ctx.abortSignal);
|
||||
return { stop: () => {} };
|
||||
}
|
||||
if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) {
|
||||
log?.warn?.(
|
||||
`Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`,
|
||||
);
|
||||
return waitUntilAbort(ctx.abortSignal);
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
log?.info?.(
|
||||
@@ -260,30 +243,18 @@ export function createSynologyChatPlugin() {
|
||||
const rt = getSynologyRuntime();
|
||||
const currentCfg = await rt.config.loadConfig();
|
||||
|
||||
// The Chat API user_id (for sending) may differ from the webhook
|
||||
// user_id (used for sessions/pairing). Use chatUserId for API calls.
|
||||
const sendUserId = msg.chatUserId ?? msg.from;
|
||||
|
||||
// Build MsgContext using SDK's finalizeInboundContext for proper normalization
|
||||
const msgCtx = rt.channel.reply.finalizeInboundContext({
|
||||
// Build MsgContext (same format as LINE/Signal/etc.)
|
||||
const msgCtx = {
|
||||
Body: msg.body,
|
||||
RawBody: msg.body,
|
||||
CommandBody: msg.body,
|
||||
From: `synology-chat:${msg.from}`,
|
||||
To: `synology-chat:${msg.from}`,
|
||||
From: msg.from,
|
||||
To: account.botName,
|
||||
SessionKey: msg.sessionKey,
|
||||
AccountId: account.accountId,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: `synology-chat:${msg.from}`,
|
||||
OriginatingChannel: CHANNEL_ID as any,
|
||||
OriginatingTo: msg.from,
|
||||
ChatType: msg.chatType,
|
||||
SenderName: msg.senderName,
|
||||
SenderId: msg.from,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
ConversationLabel: msg.senderName || msg.from,
|
||||
Timestamp: Date.now(),
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Dispatch via the SDK's buffered block dispatcher
|
||||
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
@@ -296,7 +267,7 @@ export function createSynologyChatPlugin() {
|
||||
await sendMessage(
|
||||
account.incomingUrl,
|
||||
text,
|
||||
sendUserId,
|
||||
msg.from,
|
||||
account.allowInsecureSsl,
|
||||
);
|
||||
}
|
||||
@@ -335,14 +306,13 @@ export function createSynologyChatPlugin() {
|
||||
|
||||
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
|
||||
|
||||
// Keep alive until abort signal fires.
|
||||
// The gateway expects a Promise that stays pending while the channel is running.
|
||||
// Resolving immediately triggers a restart loop.
|
||||
return waitUntilAbort(ctx.abortSignal, () => {
|
||||
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
|
||||
if (typeof unregister === "function") unregister();
|
||||
activeRouteUnregisters.delete(routeKey);
|
||||
});
|
||||
return {
|
||||
stop: () => {
|
||||
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
|
||||
if (typeof unregister === "function") unregister();
|
||||
activeRouteUnregisters.delete(routeKey);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
stopAccount: async (ctx: any) => {
|
||||
|
||||
@@ -4,18 +4,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// Mock http and https modules before importing the client
|
||||
vi.mock("node:https", () => {
|
||||
const mockRequest = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
return { default: { request: mockRequest, get: mockGet }, request: mockRequest, get: mockGet };
|
||||
return { default: { request: mockRequest }, request: mockRequest };
|
||||
});
|
||||
|
||||
vi.mock("node:http", () => {
|
||||
const mockRequest = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
return { default: { request: mockRequest, get: mockGet }, request: mockRequest, get: mockGet };
|
||||
return { default: { request: mockRequest }, request: mockRequest };
|
||||
});
|
||||
|
||||
// Import after mocks are set up
|
||||
const { sendMessage, sendFileUrl, fetchChatUsers, resolveChatUserId } = await import("./client.js");
|
||||
const { sendMessage, sendFileUrl } = await import("./client.js");
|
||||
const https = await import("node:https");
|
||||
let fakeNowMs = 1_700_000_000_000;
|
||||
|
||||
@@ -113,122 +111,3 @@ describe("sendFileUrl", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to mock the user_list API response for fetchChatUsers / resolveChatUserId
|
||||
function mockUserListResponse(
|
||||
users: Array<{ user_id: number; username: string; nickname: string }>,
|
||||
) {
|
||||
const httpsGet = vi.mocked((https as any).get);
|
||||
httpsGet.mockImplementation((_url: any, _opts: any, callback: any) => {
|
||||
const res = new EventEmitter() as any;
|
||||
res.statusCode = 200;
|
||||
process.nextTick(() => {
|
||||
callback(res);
|
||||
res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
|
||||
res.emit("end");
|
||||
});
|
||||
const req = new EventEmitter() as any;
|
||||
req.destroy = vi.fn();
|
||||
return req;
|
||||
});
|
||||
}
|
||||
|
||||
function mockUserListResponseOnce(
|
||||
users: Array<{ user_id: number; username: string; nickname: string }>,
|
||||
) {
|
||||
const httpsGet = vi.mocked((https as any).get);
|
||||
httpsGet.mockImplementationOnce((_url: any, _opts: any, callback: any) => {
|
||||
const res = new EventEmitter() as any;
|
||||
res.statusCode = 200;
|
||||
process.nextTick(() => {
|
||||
callback(res);
|
||||
res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
|
||||
res.emit("end");
|
||||
});
|
||||
const req = new EventEmitter() as any;
|
||||
req.destroy = vi.fn();
|
||||
return req;
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveChatUserId", () => {
|
||||
const baseUrl =
|
||||
"https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22";
|
||||
const baseUrl2 =
|
||||
"https://nas2.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test-2%22";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Advance time to invalidate any cached user list from previous tests
|
||||
fakeNowMs += 10 * 60 * 1000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves user by nickname (webhook username = Chat nickname)", async () => {
|
||||
mockUserListResponse([
|
||||
{ user_id: 4, username: "jmn67", nickname: "jmn" },
|
||||
{ user_id: 7, username: "she67", nickname: "sarah" },
|
||||
]);
|
||||
const result = await resolveChatUserId(baseUrl, "jmn");
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it("resolves user by username when nickname does not match", async () => {
|
||||
mockUserListResponse([
|
||||
{ user_id: 4, username: "jmn67", nickname: "" },
|
||||
{ user_id: 7, username: "she67", nickname: "sarah" },
|
||||
]);
|
||||
// Advance time to invalidate cache
|
||||
fakeNowMs += 10 * 60 * 1000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
const result = await resolveChatUserId(baseUrl, "jmn67");
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it("is case-insensitive", async () => {
|
||||
mockUserListResponse([{ user_id: 4, username: "JMN67", nickname: "JMN" }]);
|
||||
fakeNowMs += 10 * 60 * 1000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
const result = await resolveChatUserId(baseUrl, "jmn");
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it("returns undefined when user is not found", async () => {
|
||||
mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
|
||||
fakeNowMs += 10 * 60 * 1000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
const result = await resolveChatUserId(baseUrl, "unknown_user");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses method=user_list instead of method=chatbot in the API URL", async () => {
|
||||
mockUserListResponse([]);
|
||||
fakeNowMs += 10 * 60 * 1000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
await resolveChatUserId(baseUrl, "anyone");
|
||||
const httpsGet = vi.mocked((https as any).get);
|
||||
expect(httpsGet).toHaveBeenCalledWith(
|
||||
expect.stringContaining("method=user_list"),
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps user cache scoped per incoming URL", async () => {
|
||||
mockUserListResponseOnce([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
|
||||
mockUserListResponseOnce([{ user_id: 9, username: "jmn67", nickname: "jmn" }]);
|
||||
|
||||
const result1 = await resolveChatUserId(baseUrl, "jmn");
|
||||
const result2 = await resolveChatUserId(baseUrl2, "jmn");
|
||||
|
||||
expect(result1).toBe(4);
|
||||
expect(result2).toBe(9);
|
||||
const httpsGet = vi.mocked((https as any).get);
|
||||
expect(httpsGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,28 +9,6 @@ import * as https from "node:https";
|
||||
const MIN_SEND_INTERVAL_MS = 500;
|
||||
let lastSendTime = 0;
|
||||
|
||||
// --- Chat user_id resolution ---
|
||||
// Synology Chat uses two different user_id spaces:
|
||||
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
|
||||
// - Chat API user_id: global internal ID (e.g. 4)
|
||||
// The chatbot API (method=chatbot) requires the Chat API user_id in the
|
||||
// user_ids array. We resolve via the user_list API and cache the result.
|
||||
|
||||
interface ChatUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
type ChatUserCacheEntry = {
|
||||
users: ChatUser[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
// Cache user lists per bot endpoint to avoid cross-account bleed.
|
||||
const chatUserCache = new Map<string, ChatUserCacheEntry>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Send a text message to Synology Chat via the incoming webhook.
|
||||
*
|
||||
@@ -114,107 +92,6 @@ export async function sendFileUrl(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of Chat users visible to this bot via the user_list API.
|
||||
* Results are cached for CACHE_TTL_MS to avoid excessive API calls.
|
||||
*
|
||||
* The user_list endpoint uses the same base URL as the chatbot API but
|
||||
* with method=user_list instead of method=chatbot.
|
||||
*/
|
||||
export async function fetchChatUsers(
|
||||
incomingUrl: string,
|
||||
allowInsecureSsl = true,
|
||||
log?: { warn: (...args: unknown[]) => void },
|
||||
): Promise<ChatUser[]> {
|
||||
const now = Date.now();
|
||||
const listUrl = incomingUrl.replace(/method=\w+/, "method=user_list");
|
||||
const cached = chatUserCache.get(listUrl);
|
||||
if (cached && now - cached.cachedAt < CACHE_TTL_MS) {
|
||||
return cached.users;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(listUrl);
|
||||
} catch {
|
||||
log?.warn("fetchChatUsers: invalid user_list URL, using cached data");
|
||||
resolve(cached?.users ?? []);
|
||||
return;
|
||||
}
|
||||
const transport = parsedUrl.protocol === "https:" ? https : http;
|
||||
|
||||
transport
|
||||
.get(listUrl, { rejectUnauthorized: !allowInsecureSsl } as any, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c: Buffer) => {
|
||||
data += c.toString();
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const result = JSON.parse(data);
|
||||
if (result.success && result.data?.users) {
|
||||
const users = result.data.users.map((u: any) => ({
|
||||
user_id: u.user_id,
|
||||
username: u.username || "",
|
||||
nickname: u.nickname || "",
|
||||
}));
|
||||
chatUserCache.set(listUrl, {
|
||||
users,
|
||||
cachedAt: now,
|
||||
});
|
||||
resolve(users);
|
||||
} else {
|
||||
log?.warn(
|
||||
`fetchChatUsers: API returned success=${result.success}, using cached data`,
|
||||
);
|
||||
resolve(cached?.users ?? []);
|
||||
}
|
||||
} catch {
|
||||
log?.warn("fetchChatUsers: failed to parse user_list response");
|
||||
resolve(cached?.users ?? []);
|
||||
}
|
||||
});
|
||||
})
|
||||
.on("error", (err) => {
|
||||
log?.warn(`fetchChatUsers: HTTP error — ${err instanceof Error ? err.message : err}`);
|
||||
resolve(cached?.users ?? []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a webhook username to the correct Chat API user_id.
|
||||
*
|
||||
* Synology Chat outgoing webhooks send a user_id that may NOT match the
|
||||
* Chat-internal user_id needed by the chatbot API (method=chatbot).
|
||||
* The webhook's "username" field corresponds to the Chat user's "nickname".
|
||||
*
|
||||
* @param incomingUrl - Bot incoming webhook URL (used to derive user_list URL)
|
||||
* @param webhookUsername - The username from the outgoing webhook payload
|
||||
* @param allowInsecureSsl - Skip TLS verification
|
||||
* @returns The correct Chat user_id, or undefined if not found
|
||||
*/
|
||||
export async function resolveChatUserId(
|
||||
incomingUrl: string,
|
||||
webhookUsername: string,
|
||||
allowInsecureSsl = true,
|
||||
log?: { warn: (...args: unknown[]) => void },
|
||||
): Promise<number | undefined> {
|
||||
const users = await fetchChatUsers(incomingUrl, allowInsecureSsl, log);
|
||||
const lower = webhookUsername.toLowerCase();
|
||||
|
||||
// Match by nickname first (webhook "username" field = Chat "nickname")
|
||||
const byNickname = users.find((u) => u.nickname.toLowerCase() === lower);
|
||||
if (byNickname) return byNickname.user_id;
|
||||
|
||||
// Then by username
|
||||
const byUsername = users.find((u) => u.username.toLowerCase() === lower);
|
||||
if (byUsername) return byUsername.user_id;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function doPost(url: string, body: string, allowInsecureSsl = true): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let parsedUrl: URL;
|
||||
|
||||
@@ -2,22 +2,10 @@ import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
export function makeReq(method: string, body: string): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & { destroyed: boolean };
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = method;
|
||||
req.headers = {};
|
||||
req.socket = { remoteAddress: "127.0.0.1" } as unknown as IncomingMessage["socket"];
|
||||
req.destroyed = false;
|
||||
req.destroy = ((_: Error | undefined) => {
|
||||
if (req.destroyed) {
|
||||
return req;
|
||||
}
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
}) as IncomingMessage["destroy"];
|
||||
process.nextTick(() => {
|
||||
if (req.destroyed) {
|
||||
return;
|
||||
}
|
||||
req.emit("data", Buffer.from(body));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
|
||||
import type { ResolvedSynologyChatAccount } from "./types.js";
|
||||
import {
|
||||
clearSynologyWebhookRateLimiterStateForTest,
|
||||
createWebhookHandler,
|
||||
} from "./webhook-handler.js";
|
||||
|
||||
// Mock sendMessage and resolveChatUserId to prevent real HTTP calls
|
||||
// Mock sendMessage to prevent real HTTP calls
|
||||
vi.mock("./client.js", () => ({
|
||||
sendMessage: vi.fn().mockResolvedValue(true),
|
||||
resolveChatUserId: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function makeAccount(
|
||||
@@ -32,76 +30,6 @@ function makeAccount(
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(
|
||||
method: string,
|
||||
body: string,
|
||||
opts: { headers?: Record<string, string>; url?: string } = {},
|
||||
): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed: boolean;
|
||||
};
|
||||
req.method = method;
|
||||
req.headers = opts.headers ?? {};
|
||||
req.url = opts.url ?? "/webhook/synology";
|
||||
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
||||
req.destroyed = false;
|
||||
req.destroy = ((_: Error | undefined) => {
|
||||
if (req.destroyed) {
|
||||
return req;
|
||||
}
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
}) as IncomingMessage["destroy"];
|
||||
|
||||
// Simulate body delivery
|
||||
process.nextTick(() => {
|
||||
if (req.destroyed) {
|
||||
return;
|
||||
}
|
||||
req.emit("data", Buffer.from(body));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
function makeStalledReq(method: string): IncomingMessage {
|
||||
const req = new EventEmitter() as IncomingMessage & {
|
||||
destroyed: boolean;
|
||||
};
|
||||
req.method = method;
|
||||
req.headers = {};
|
||||
req.socket = { remoteAddress: "127.0.0.1" } as any;
|
||||
req.destroyed = false;
|
||||
req.destroy = ((_: Error | undefined) => {
|
||||
if (req.destroyed) {
|
||||
return req;
|
||||
}
|
||||
req.destroyed = true;
|
||||
return req;
|
||||
}) as IncomingMessage["destroy"];
|
||||
return req;
|
||||
}
|
||||
|
||||
function makeRes(): ServerResponse & { _status: number; _body: string } {
|
||||
const res = {
|
||||
_status: 0,
|
||||
_body: "",
|
||||
writeHead(statusCode: number, _headers?: Record<string, string>) {
|
||||
res._status = statusCode;
|
||||
},
|
||||
end(body?: string) {
|
||||
res._body = body ?? "";
|
||||
},
|
||||
} as any;
|
||||
return res;
|
||||
}
|
||||
|
||||
function makeFormBody(fields: Record<string, string>): string {
|
||||
return Object.entries(fields)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
const validBody = makeFormBody({
|
||||
token: "valid-token",
|
||||
user_id: "123",
|
||||
@@ -167,29 +95,6 @@ describe("createWebhookHandler", () => {
|
||||
expect(res._status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 408 when request body times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount(),
|
||||
deliver: vi.fn(),
|
||||
log,
|
||||
});
|
||||
|
||||
const req = makeStalledReq("POST");
|
||||
const res = makeRes();
|
||||
const run = handler(req, res);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await run;
|
||||
|
||||
expect(res._status).toBe(408);
|
||||
expect(res._body).toContain("timeout");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 401 for invalid token", async () => {
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount(),
|
||||
@@ -210,85 +115,6 @@ describe("createWebhookHandler", () => {
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts application/json with alias fields", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(null);
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ accountId: "json-test-" + Date.now() }),
|
||||
deliver,
|
||||
log,
|
||||
});
|
||||
|
||||
const req = makeReq(
|
||||
"POST",
|
||||
JSON.stringify({
|
||||
token: "valid-token",
|
||||
userId: "123",
|
||||
name: "json-user",
|
||||
message: "Hello from json",
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(204);
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "Hello from json",
|
||||
from: "123",
|
||||
senderName: "json-user",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts token from query when body token is absent", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(null);
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ accountId: "query-token-test-" + Date.now() }),
|
||||
deliver,
|
||||
log,
|
||||
});
|
||||
|
||||
const req = makeReq(
|
||||
"POST",
|
||||
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
|
||||
{
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
url: "/webhook/synology?token=valid-token",
|
||||
},
|
||||
);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(204);
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts token from authorization header when body token is absent", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(null);
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ accountId: "header-token-test-" + Date.now() }),
|
||||
deliver,
|
||||
log,
|
||||
});
|
||||
|
||||
const req = makeReq(
|
||||
"POST",
|
||||
makeFormBody({ user_id: "123", username: "testuser", text: "hello" }),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
authorization: "Bearer valid-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(204);
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 for unauthorized user with allowlist policy", async () => {
|
||||
await expectForbiddenByPolicy({
|
||||
account: {
|
||||
@@ -341,7 +167,7 @@ describe("createWebhookHandler", () => {
|
||||
const req1 = makeReq("POST", validBody);
|
||||
const res1 = makeRes();
|
||||
await handler(req1, res1);
|
||||
expect(res1._status).toBe(204);
|
||||
expect(res1._status).toBe(200);
|
||||
|
||||
// Second request should be rate limited
|
||||
const req2 = makeReq("POST", validBody);
|
||||
@@ -370,12 +196,12 @@ describe("createWebhookHandler", () => {
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(204);
|
||||
expect(res._status).toBe(200);
|
||||
// deliver should have been called with the stripped text
|
||||
expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ body: "Hello there" }));
|
||||
});
|
||||
|
||||
it("responds 204 immediately and delivers async", async () => {
|
||||
it("responds 200 immediately and delivers async", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue("Bot reply");
|
||||
const handler = createWebhookHandler({
|
||||
account: makeAccount({ accountId: "async-test-" + Date.now() }),
|
||||
@@ -387,8 +213,8 @@ describe("createWebhookHandler", () => {
|
||||
const res = makeRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._status).toBe(204);
|
||||
expect(res._body).toBe("");
|
||||
expect(res._status).toBe(200);
|
||||
expect(res._body).toContain("Processing");
|
||||
expect(deliver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "Hello bot",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
/**
|
||||
* Inbound webhook handler for Synology Chat outgoing webhooks.
|
||||
* Parses form-urlencoded/JSON body, validates security, delivers to agent.
|
||||
* Parses form-urlencoded body, validates security, delivers to agent.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import * as querystring from "node:querystring";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { sendMessage, resolveChatUserId } from "./client.js";
|
||||
import { sendMessage } from "./client.js";
|
||||
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
@@ -39,182 +34,56 @@ export function getSynologyWebhookRateLimiterCountForTest(): number {
|
||||
}
|
||||
|
||||
/** Read the full request body as a string. */
|
||||
async function readBody(req: IncomingMessage): Promise<
|
||||
| { ok: true; body: string }
|
||||
| {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
try {
|
||||
const body = await readRequestBodyWithLimit(req, {
|
||||
maxBytes: 1_048_576,
|
||||
timeoutMs: 30_000,
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
const maxSize = 1_048_576; // 1MB
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > maxSize) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
return { ok: true, body };
|
||||
} catch (err) {
|
||||
if (isRequestBodyLimitError(err)) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: err.statusCode,
|
||||
error: requestBodyErrorToText(err.code),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 400,
|
||||
error: "Invalid request body",
|
||||
};
|
||||
}
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonEmptyString(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const normalized = firstNonEmptyString(item);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value === null || value === undefined) return undefined;
|
||||
const str = String(value).trim();
|
||||
return str.length > 0 ? str : undefined;
|
||||
}
|
||||
/** Parse form-urlencoded body into SynologyWebhookPayload. */
|
||||
function parsePayload(body: string): SynologyWebhookPayload | null {
|
||||
const parsed = querystring.parse(body);
|
||||
|
||||
function pickAlias(record: Record<string, unknown>, aliases: string[]): string | undefined {
|
||||
for (const alias of aliases) {
|
||||
const normalized = firstNonEmptyString(record[alias]);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseQueryParams(req: IncomingMessage): Record<string, unknown> {
|
||||
try {
|
||||
const url = new URL(req.url ?? "", "http://localhost");
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseFormBody(body: string): Record<string, unknown> {
|
||||
return querystring.parse(body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseJsonBody(body: string): Record<string, unknown> {
|
||||
if (!body.trim()) return {};
|
||||
const parsed = JSON.parse(body);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error("Invalid JSON body");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function headerValue(header: string | string[] | undefined): string | undefined {
|
||||
return firstNonEmptyString(header);
|
||||
}
|
||||
|
||||
function extractTokenFromHeaders(req: IncomingMessage): string | undefined {
|
||||
const explicit =
|
||||
headerValue(req.headers["x-synology-token"]) ??
|
||||
headerValue(req.headers["x-webhook-token"]) ??
|
||||
headerValue(req.headers["x-openclaw-token"]);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const auth = headerValue(req.headers.authorization);
|
||||
if (!auth) return undefined;
|
||||
|
||||
const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (bearerMatch?.[1]) return bearerMatch[1].trim();
|
||||
return auth.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse/normalize incoming webhook payload.
|
||||
*
|
||||
* Supports:
|
||||
* - application/x-www-form-urlencoded
|
||||
* - application/json
|
||||
*
|
||||
* Token resolution order: body.token -> query.token -> headers
|
||||
* Field aliases:
|
||||
* - user_id <- user_id | userId | user
|
||||
* - text <- text | message | content
|
||||
*/
|
||||
function parsePayload(req: IncomingMessage, body: string): SynologyWebhookPayload | null {
|
||||
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
|
||||
|
||||
let bodyFields: Record<string, unknown> = {};
|
||||
if (contentType.includes("application/json")) {
|
||||
bodyFields = parseJsonBody(body);
|
||||
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
bodyFields = parseFormBody(body);
|
||||
} else {
|
||||
// Fallback for clients with missing/incorrect content-type.
|
||||
// Try JSON first, then form-urlencoded.
|
||||
try {
|
||||
bodyFields = parseJsonBody(body);
|
||||
} catch {
|
||||
bodyFields = parseFormBody(body);
|
||||
}
|
||||
}
|
||||
|
||||
const queryFields = parseQueryParams(req);
|
||||
const headerToken = extractTokenFromHeaders(req);
|
||||
|
||||
const token =
|
||||
pickAlias(bodyFields, ["token"]) ?? pickAlias(queryFields, ["token"]) ?? headerToken;
|
||||
const userId =
|
||||
pickAlias(bodyFields, ["user_id", "userId", "user"]) ??
|
||||
pickAlias(queryFields, ["user_id", "userId", "user"]);
|
||||
const text =
|
||||
pickAlias(bodyFields, ["text", "message", "content"]) ??
|
||||
pickAlias(queryFields, ["text", "message", "content"]);
|
||||
const token = String(parsed.token ?? "");
|
||||
const userId = String(parsed.user_id ?? "");
|
||||
const username = String(parsed.username ?? "unknown");
|
||||
const text = String(parsed.text ?? "");
|
||||
|
||||
if (!token || !userId || !text) return null;
|
||||
|
||||
return {
|
||||
token,
|
||||
channel_id:
|
||||
pickAlias(bodyFields, ["channel_id"]) ?? pickAlias(queryFields, ["channel_id"]) ?? undefined,
|
||||
channel_name:
|
||||
pickAlias(bodyFields, ["channel_name"]) ??
|
||||
pickAlias(queryFields, ["channel_name"]) ??
|
||||
undefined,
|
||||
channel_id: parsed.channel_id ? String(parsed.channel_id) : undefined,
|
||||
channel_name: parsed.channel_name ? String(parsed.channel_name) : undefined,
|
||||
user_id: userId,
|
||||
username:
|
||||
pickAlias(bodyFields, ["username", "user_name", "name"]) ??
|
||||
pickAlias(queryFields, ["username", "user_name", "name"]) ??
|
||||
"unknown",
|
||||
post_id: pickAlias(bodyFields, ["post_id"]) ?? pickAlias(queryFields, ["post_id"]) ?? undefined,
|
||||
timestamp:
|
||||
pickAlias(bodyFields, ["timestamp"]) ?? pickAlias(queryFields, ["timestamp"]) ?? undefined,
|
||||
username,
|
||||
post_id: parsed.post_id ? String(parsed.post_id) : undefined,
|
||||
timestamp: parsed.timestamp ? String(parsed.timestamp) : undefined,
|
||||
text,
|
||||
trigger_word:
|
||||
pickAlias(bodyFields, ["trigger_word", "triggerWord"]) ??
|
||||
pickAlias(queryFields, ["trigger_word", "triggerWord"]) ??
|
||||
undefined,
|
||||
trigger_word: parsed.trigger_word ? String(parsed.trigger_word) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a JSON response. */
|
||||
function respondJson(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
|
||||
function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/** Send a no-content ACK. */
|
||||
function respondNoContent(res: ServerResponse) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
export interface WebhookHandlerDeps {
|
||||
account: ResolvedSynologyChatAccount;
|
||||
deliver: (msg: {
|
||||
@@ -225,8 +94,6 @@ export interface WebhookHandlerDeps {
|
||||
chatType: string;
|
||||
sessionKey: string;
|
||||
accountId: string;
|
||||
/** Chat API user_id for sending replies (may differ from webhook user_id) */
|
||||
chatUserId?: string;
|
||||
}) => Promise<string | null>;
|
||||
log?: {
|
||||
info: (...args: unknown[]) => void;
|
||||
@@ -239,13 +106,13 @@ export interface WebhookHandlerDeps {
|
||||
* Create an HTTP request handler for Synology Chat outgoing webhooks.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Parses form-urlencoded/JSON payload
|
||||
* 1. Parses form-urlencoded body
|
||||
* 2. Validates token (constant-time)
|
||||
* 3. Checks user allowlist
|
||||
* 4. Checks rate limit
|
||||
* 5. Sanitizes input
|
||||
* 6. Immediately ACKs request (204)
|
||||
* 7. Delivers to the agent asynchronously and sends final reply via incomingUrl
|
||||
* 6. Delivers to the agent via deliver()
|
||||
* 7. Sends the agent response back to Synology Chat
|
||||
*/
|
||||
export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
const { account, deliver, log } = deps;
|
||||
@@ -254,36 +121,31 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
// Only accept POST
|
||||
if (req.method !== "POST") {
|
||||
respondJson(res, 405, { error: "Method not allowed" });
|
||||
respond(res, 405, { error: "Method not allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse body
|
||||
const bodyResult = await readBody(req);
|
||||
if (!bodyResult.ok) {
|
||||
log?.error("Failed to read request body", bodyResult.error);
|
||||
respondJson(res, bodyResult.statusCode, { error: bodyResult.error });
|
||||
let body: string;
|
||||
try {
|
||||
body = await readBody(req);
|
||||
} catch (err) {
|
||||
log?.error("Failed to read request body", err);
|
||||
respond(res, 400, { error: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
let payload: SynologyWebhookPayload | null = null;
|
||||
try {
|
||||
payload = parsePayload(req, bodyResult.body);
|
||||
} catch (err) {
|
||||
log?.warn("Failed to parse webhook payload", err);
|
||||
respondJson(res, 400, { error: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
const payload = parsePayload(body);
|
||||
if (!payload) {
|
||||
respondJson(res, 400, { error: "Missing required fields (token, user_id, text)" });
|
||||
respond(res, 400, { error: "Missing required fields (token, user_id, text)" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Token validation
|
||||
if (!validateToken(payload.token, account.token)) {
|
||||
log?.warn(`Invalid token from ${req.socket?.remoteAddress}`);
|
||||
respondJson(res, 401, { error: "Invalid token" });
|
||||
respond(res, 401, { error: "Invalid token" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -291,25 +153,25 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
const auth = authorizeUserForDm(payload.user_id, account.dmPolicy, account.allowedUserIds);
|
||||
if (!auth.allowed) {
|
||||
if (auth.reason === "disabled") {
|
||||
respondJson(res, 403, { error: "DMs are disabled" });
|
||||
respond(res, 403, { error: "DMs are disabled" });
|
||||
return;
|
||||
}
|
||||
if (auth.reason === "allowlist-empty") {
|
||||
log?.warn("Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message");
|
||||
respondJson(res, 403, {
|
||||
respond(res, 403, {
|
||||
error: "Allowlist is empty. Configure allowedUserIds or use dmPolicy=open.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
log?.warn(`Unauthorized user: ${payload.user_id}`);
|
||||
respondJson(res, 403, { error: "User not authorized" });
|
||||
respond(res, 403, { error: "User not authorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (!rateLimiter.check(payload.user_id)) {
|
||||
log?.warn(`Rate limit exceeded for user: ${payload.user_id}`);
|
||||
respondJson(res, 429, { error: "Rate limit exceeded" });
|
||||
respond(res, 429, { error: "Rate limit exceeded" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,39 +184,18 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
}
|
||||
|
||||
if (!cleanText) {
|
||||
respondNoContent(res);
|
||||
respond(res, 200, { text: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
|
||||
log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`);
|
||||
|
||||
// ACK immediately so Synology Chat won't remain in "Processing..."
|
||||
respondNoContent(res);
|
||||
|
||||
// Default to webhook user_id; may be replaced with Chat API user_id below.
|
||||
let replyUserId = payload.user_id;
|
||||
// Respond 200 immediately to avoid Synology Chat timeout
|
||||
respond(res, 200, { text: "Processing..." });
|
||||
|
||||
// Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout)
|
||||
try {
|
||||
// Resolve the Chat-internal user_id for sending replies.
|
||||
// Synology Chat outgoing webhooks use a per-integration user_id that may
|
||||
// differ from the global Chat API user_id required by method=chatbot.
|
||||
// We resolve via the user_list API, matching by nickname/username.
|
||||
const chatUserId = await resolveChatUserId(
|
||||
account.incomingUrl,
|
||||
payload.username,
|
||||
account.allowInsecureSsl,
|
||||
log,
|
||||
);
|
||||
if (chatUserId !== undefined) {
|
||||
replyUserId = String(chatUserId);
|
||||
} else {
|
||||
log?.warn(
|
||||
`Could not resolve Chat API user_id for "${payload.username}" — falling back to webhook user_id ${payload.user_id}. Reply delivery may fail.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionKey = `synology-chat-${payload.user_id}`;
|
||||
const deliverPromise = deliver({
|
||||
body: cleanText,
|
||||
@@ -364,7 +205,6 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
chatType: "direct",
|
||||
sessionKey,
|
||||
accountId: account.accountId,
|
||||
chatUserId: replyUserId,
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((_, reject) =>
|
||||
@@ -373,11 +213,11 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
|
||||
const reply = await Promise.race([deliverPromise, timeoutPromise]);
|
||||
|
||||
// Send reply back to Synology Chat using the resolved Chat user_id
|
||||
// Send reply back to Synology Chat
|
||||
if (reply) {
|
||||
await sendMessage(account.incomingUrl, reply, replyUserId, account.allowInsecureSsl);
|
||||
await sendMessage(account.incomingUrl, reply, payload.user_id, account.allowInsecureSsl);
|
||||
const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;
|
||||
log?.info(`Reply sent to ${payload.username} (${replyUserId}): ${replyPreview}`);
|
||||
log?.info(`Reply sent to ${payload.username} (${payload.user_id}): ${replyPreview}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
|
||||
@@ -385,7 +225,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
|
||||
await sendMessage(
|
||||
account.incomingUrl,
|
||||
"Sorry, an error occurred while processing your message.",
|
||||
replyUserId,
|
||||
payload.user_id,
|
||||
account.allowInsecureSsl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
|
||||
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { sendReactionZalouser } from "./send.js";
|
||||
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||
|
||||
describe("zalouser outbound chunker", () => {
|
||||
it("chunks without empty strings and respects limit", () => {
|
||||
@@ -29,34 +18,6 @@ describe("zalouser outbound chunker", () => {
|
||||
});
|
||||
|
||||
describe("zalouser channel policies", () => {
|
||||
beforeEach(() => {
|
||||
mockSendReaction.mockClear();
|
||||
mockSendReaction.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("resolves requireMention from group config", () => {
|
||||
const resolveRequireMention = zalouserPlugin.groups?.resolveRequireMention;
|
||||
expect(resolveRequireMention).toBeTypeOf("function");
|
||||
if (!resolveRequireMention) {
|
||||
return;
|
||||
}
|
||||
const requireMention = resolveRequireMention({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "123",
|
||||
groupChannel: "123",
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves group tool policy by explicit group id", () => {
|
||||
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
||||
expect(resolveToolPolicy).toBeTypeOf("function");
|
||||
@@ -102,39 +63,4 @@ describe("zalouser channel policies", () => {
|
||||
});
|
||||
expect(policy).toEqual({ deny: ["system.run"] });
|
||||
});
|
||||
|
||||
it("handles react action", async () => {
|
||||
const actions = zalouserPlugin.actions;
|
||||
expect(actions?.listActions?.({ cfg: { channels: { zalouser: { enabled: true } } } })).toEqual([
|
||||
"react",
|
||||
]);
|
||||
const result = await actions?.handleAction?.({
|
||||
channel: "zalouser",
|
||||
action: "react",
|
||||
params: {
|
||||
threadId: "123456",
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "default",
|
||||
threadId: "123456",
|
||||
isGroup: false,
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
remove: false,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelDock,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
GroupToolPolicyConfig,
|
||||
@@ -35,7 +34,7 @@ import {
|
||||
import { ZalouserConfigSchema } from "./config-schema.js";
|
||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||
import { probeZalouser } from "./probe.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
import {
|
||||
listZaloFriendsMatching,
|
||||
@@ -136,127 +135,6 @@ function resolveZalouserGroupToolPolicy(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const candidates = [params.groupId?.trim(), params.groupChannel?.trim()].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const entry = groups[candidate];
|
||||
if (typeof entry?.requireMention === "boolean") {
|
||||
return entry.requireMention;
|
||||
}
|
||||
}
|
||||
if (typeof groups["*"]?.requireMention === "boolean") {
|
||||
return groups["*"].requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveZalouserReactionMessageIds(params: {
|
||||
messageId?: string;
|
||||
cliMsgId?: string;
|
||||
currentMessageId?: string | number;
|
||||
}): { msgId: string; cliMsgId: string } | null {
|
||||
const explicitMessageId = params.messageId?.trim() ?? "";
|
||||
const explicitCliMsgId = params.cliMsgId?.trim() ?? "";
|
||||
if (explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
|
||||
const current =
|
||||
typeof params.currentMessageId === "number" ? String(params.currentMessageId) : "";
|
||||
const currentRaw =
|
||||
typeof params.currentMessageId === "string" ? params.currentMessageId.trim() : current;
|
||||
if (!currentRaw) {
|
||||
return null;
|
||||
}
|
||||
const [msgIdPart, cliMsgIdPart] = currentRaw.split(":").map((value) => value.trim());
|
||||
if (msgIdPart && cliMsgIdPart) {
|
||||
return { msgId: msgIdPart, cliMsgId: cliMsgIdPart };
|
||||
}
|
||||
if (explicitMessageId && !explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: currentRaw };
|
||||
}
|
||||
if (!explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: currentRaw, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
return { msgId: currentRaw, cliMsgId: currentRaw };
|
||||
}
|
||||
|
||||
const zalouserMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listZalouserAccountIds(cfg)
|
||||
.map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return ["react"];
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Zalouser action ${action} not supported`);
|
||||
}
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const threadId =
|
||||
(typeof params.threadId === "string" ? params.threadId.trim() : "") ||
|
||||
(typeof params.to === "string" ? params.to.trim() : "") ||
|
||||
(typeof params.chatId === "string" ? params.chatId.trim() : "") ||
|
||||
(toolContext?.currentChannelId?.trim() ?? "");
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser react requires threadId (or to/chatId).");
|
||||
}
|
||||
const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
|
||||
if (!emoji) {
|
||||
throw new Error("Zalouser react requires emoji.");
|
||||
}
|
||||
const ids = resolveZalouserReactionMessageIds({
|
||||
messageId: typeof params.messageId === "string" ? params.messageId : undefined,
|
||||
cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
|
||||
currentMessageId: toolContext?.currentMessageId,
|
||||
});
|
||||
if (!ids) {
|
||||
throw new Error(
|
||||
"Zalouser react requires messageId + cliMsgId (or a current message context id).",
|
||||
);
|
||||
}
|
||||
const result = await sendReactionZalouser({
|
||||
profile: account.profile,
|
||||
threadId,
|
||||
isGroup: params.isGroup === true,
|
||||
msgId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
emoji,
|
||||
remove: params.remove === true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to react on Zalo message");
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
params.remove === true
|
||||
? `Removed reaction ${emoji} from ${ids.msgId}`
|
||||
: `Reacted ${emoji} on ${ids.msgId}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
messageId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
threadId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
@@ -274,7 +152,7 @@ export const zalouserDock: ChannelDock = {
|
||||
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
@@ -357,13 +235,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
actions: zalouserMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
|
||||
@@ -6,7 +6,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,15 +5,9 @@ import { setZalouserRuntime } from "./runtime.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __testing } from "./monitor.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
authenticated: true,
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
function installRuntime(params: { commandAuthorized: boolean }) {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
||||
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
||||
});
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
|
||||
buildPairingReply: vi.fn(() => "pair"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => params.commandAuthorized),
|
||||
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
shouldHandleTextCommands: vi.fn(() => true),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => []),
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(input) => input.explicit?.isExplicitlyMentioned === true,
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: vi.fn((input) => {
|
||||
const cfg = input.cfg as OpenClawConfig;
|
||||
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
|
||||
const groupEntry = input.groupId ? groupCfg[input.groupId] : undefined;
|
||||
const defaultEntry = groupCfg["*"];
|
||||
if (typeof groupEntry?.requireMention === "boolean") {
|
||||
return groupEntry.requireMention;
|
||||
}
|
||||
if (typeof defaultEntry?.requireMention === "boolean") {
|
||||
return defaultEntry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:zalouser:group:1",
|
||||
accountId: "default",
|
||||
mainSessionKey: "agent:main:main",
|
||||
})),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp"),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordInboundSession: vi.fn(async () => {}),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
|
||||
formatAgentEnvelope: vi.fn(({ body }) => body),
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "code"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
resolveChunkMode: vi.fn(() => "line"),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return { dispatchReplyWithBufferedBlockDispatcher };
|
||||
}
|
||||
|
||||
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "g-1",
|
||||
isGroup: true,
|
||||
senderId: "123",
|
||||
senderName: "Alice",
|
||||
groupName: "Team",
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "m-1",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
canResolveExplicitMention: true,
|
||||
implicitMention: false,
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalouser monitor group mention gating", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageZalouserMock.mockClear();
|
||||
sendTypingZalouserMock.mockClear();
|
||||
sendDeliveredZalouserMock.mockClear();
|
||||
sendSeenZalouserMock.mockClear();
|
||||
});
|
||||
|
||||
it("skips unmentioned group messages when requireMention=true", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage(),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
}),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authorized control commands to bypass mention gating", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: true,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "/status",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
}),
|
||||
account: createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,10 @@ import type {
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
resolveOutboundMediaUrls,
|
||||
mergeAllowlist,
|
||||
resolveMentionGatingWithBypass,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveSenderCommandAuthorization,
|
||||
@@ -19,19 +17,9 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendMessageZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
import {
|
||||
listZaloFriends,
|
||||
listZaloGroups,
|
||||
resolveZaloGroupContext,
|
||||
startZaloListener,
|
||||
} from "./zalo-js.js";
|
||||
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
|
||||
|
||||
export type ZalouserMonitorOptions = {
|
||||
account: ResolvedZalouserAccount;
|
||||
@@ -101,7 +89,7 @@ function normalizeGroupSlug(raw?: string | null): string {
|
||||
function isGroupAllowed(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
}): boolean {
|
||||
const groups = params.groups ?? {};
|
||||
const keys = Object.keys(groups);
|
||||
@@ -128,48 +116,6 @@ function isGroupAllowed(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveGroupRequireMention(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
|
||||
}): boolean {
|
||||
const groups = params.groups ?? {};
|
||||
const candidates = [
|
||||
params.groupId,
|
||||
`group:${params.groupId}`,
|
||||
params.groupName ?? "",
|
||||
normalizeGroupSlug(params.groupName ?? ""),
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const entry = groups[candidate];
|
||||
if (typeof entry?.requireMention === "boolean") {
|
||||
return entry.requireMention;
|
||||
}
|
||||
}
|
||||
if (typeof groups["*"]?.requireMention === "boolean") {
|
||||
return groups["*"].requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendZalouserDeliveryAcks(params: {
|
||||
profile: string;
|
||||
isGroup: boolean;
|
||||
message: NonNullable<ZaloInboundMessage["eventMessage"]>;
|
||||
}): Promise<void> {
|
||||
await sendDeliveredZalouser({
|
||||
profile: params.profile,
|
||||
isGroup: params.isGroup,
|
||||
message: params.message,
|
||||
isSeen: true,
|
||||
});
|
||||
await sendSeenZalouser({
|
||||
profile: params.profile,
|
||||
isGroup: params.isGroup,
|
||||
message: params.message,
|
||||
});
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZaloInboundMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
@@ -197,32 +143,7 @@ async function processMessage(
|
||||
return;
|
||||
}
|
||||
const senderName = message.senderName ?? "";
|
||||
const configuredGroupName = message.groupName?.trim() || "";
|
||||
const groupContext =
|
||||
isGroup && !configuredGroupName
|
||||
? await resolveZaloGroupContext(account.profile, chatId).catch((err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`zalouser: group context lookup failed for ${chatId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const groupName = configuredGroupName || groupContext?.name?.trim() || "";
|
||||
const groupMembers = groupContext?.members?.slice(0, 20).join(", ") || undefined;
|
||||
|
||||
if (message.eventMessage) {
|
||||
try {
|
||||
await sendZalouserDeliveryAcks({
|
||||
profile: account.profile,
|
||||
isGroup,
|
||||
message: message.eventMessage,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `zalouser: delivery/seen ack failed for ${chatId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const groupName = message.groupName ?? "";
|
||||
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
||||
@@ -317,8 +238,11 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
|
||||
if (isGroup && hasControlCommand && commandAuthorized !== true) {
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
@@ -342,45 +266,6 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
|
||||
const requireMention = isGroup
|
||||
? resolveGroupRequireMention({
|
||||
groupId: chatId,
|
||||
groupName,
|
||||
groups,
|
||||
})
|
||||
: false;
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
||||
const explicitMention = {
|
||||
hasAnyMention: message.hasAnyMention === true,
|
||||
isExplicitlyMentioned: message.wasExplicitlyMentioned === true,
|
||||
canResolveExplicit: message.canResolveExplicitMention === true,
|
||||
};
|
||||
const wasMentioned = isGroup
|
||||
? core.channel.mentions.matchesMentionWithExplicit({
|
||||
text: rawBody,
|
||||
mentionRegexes,
|
||||
explicit: explicitMention,
|
||||
})
|
||||
: true;
|
||||
const mentionGate = resolveMentionGatingWithBypass({
|
||||
isGroup,
|
||||
requireMention,
|
||||
canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
|
||||
wasMentioned,
|
||||
implicitMention: message.implicitMention === true,
|
||||
hasAnyMention: explicitMention.hasAnyMention,
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "zalouser",
|
||||
}),
|
||||
hasControlCommand,
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
});
|
||||
if (isGroup && mentionGate.shouldSkip) {
|
||||
logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
@@ -410,20 +295,12 @@ async function processMessage(
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: isGroup ? groupName || undefined : undefined,
|
||||
GroupChannel: isGroup ? groupName || undefined : undefined,
|
||||
GroupMembers: isGroup ? groupMembers : undefined,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`,
|
||||
MessageSidFull:
|
||||
message.msgId && message.cliMsgId
|
||||
? `${message.msgId}:${message.cliMsgId}`
|
||||
: (message.msgId ?? message.cliMsgId ?? undefined),
|
||||
OriginatingChannel: "zalouser",
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
@@ -443,24 +320,12 @@ async function processMessage(
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
await sendTypingZalouser(chatId, {
|
||||
profile: account.profile,
|
||||
isGroup,
|
||||
});
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deliverZalouserReply({
|
||||
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||
|
||||
@@ -1,46 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendImageZalouser,
|
||||
sendLinkZalouser,
|
||||
sendMessageZalouser,
|
||||
sendReactionZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
sendZaloLink: vi.fn(),
|
||||
sendZaloTypingEvent: vi.fn(),
|
||||
sendZaloReaction: vi.fn(),
|
||||
sendZaloDeliveredEvent: vi.fn(),
|
||||
sendZaloSeenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendText = vi.mocked(sendZaloTextMessage);
|
||||
const mockSendLink = vi.mocked(sendZaloLink);
|
||||
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
|
||||
const mockSendReaction = vi.mocked(sendZaloReaction);
|
||||
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
|
||||
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
|
||||
|
||||
describe("zalouser send helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockSendText.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockSendTyping.mockReset();
|
||||
mockSendReaction.mockReset();
|
||||
mockSendDelivered.mockReset();
|
||||
mockSendSeen.mockReset();
|
||||
});
|
||||
|
||||
it("delegates text send to JS transport", async () => {
|
||||
@@ -89,69 +62,4 @@ describe("zalouser send helpers", () => {
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "boom" });
|
||||
});
|
||||
|
||||
it("delegates typing helper to JS transport", async () => {
|
||||
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
|
||||
|
||||
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
|
||||
profile: "p4",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates reaction helper to JS transport", async () => {
|
||||
mockSendReaction.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await sendReactionZalouser({
|
||||
threadId: "thread-5",
|
||||
profile: "p5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "p5",
|
||||
threadId: "thread-5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
remove: undefined,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, error: undefined });
|
||||
});
|
||||
|
||||
it("delegates delivered+seen helpers to JS transport", async () => {
|
||||
mockSendDelivered.mockResolvedValueOnce();
|
||||
mockSendSeen.mockResolvedValueOnce();
|
||||
|
||||
const message = {
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
uidFrom: "1",
|
||||
idTo: "2",
|
||||
msgType: "webchat",
|
||||
st: 1,
|
||||
at: 0,
|
||||
cmd: 0,
|
||||
ts: "123",
|
||||
};
|
||||
|
||||
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
|
||||
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
|
||||
|
||||
expect(mockSendDelivered).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
isSeen: false,
|
||||
});
|
||||
expect(mockSendSeen).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
|
||||
|
||||
export type ZalouserSendOptions = ZaloSendOptions;
|
||||
export type ZalouserSendResult = ZaloSendResult;
|
||||
@@ -37,51 +30,3 @@ export async function sendLinkZalouser(
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendZaloLink(threadId, url, options);
|
||||
}
|
||||
|
||||
export async function sendTypingZalouser(
|
||||
threadId: string,
|
||||
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
await sendZaloTypingEvent(threadId, options);
|
||||
}
|
||||
|
||||
export async function sendReactionZalouser(params: {
|
||||
threadId: string;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<ZalouserSendResult> {
|
||||
const result = await sendZaloReaction({
|
||||
profile: params.profile,
|
||||
threadId: params.threadId,
|
||||
isGroup: params.isGroup,
|
||||
msgId: params.msgId,
|
||||
cliMsgId: params.cliMsgId,
|
||||
emoji: params.emoji,
|
||||
remove: params.remove,
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendDeliveredZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
await sendZaloDeliveredEvent(params);
|
||||
}
|
||||
|
||||
export async function sendSeenZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
await sendZaloSeenEvent(params);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn(),
|
||||
sendImageZalouser: vi.fn(),
|
||||
sendLinkZalouser: vi.fn(),
|
||||
sendReactionZalouser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
|
||||
@@ -16,18 +16,6 @@ export type ZaloGroupMember = {
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type ZaloInboundMessage = {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
@@ -38,11 +26,6 @@ export type ZaloInboundMessage = {
|
||||
timestampMs: number;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
hasAnyMention?: boolean;
|
||||
wasExplicitlyMentioned?: boolean;
|
||||
canResolveExplicitMention?: boolean;
|
||||
implicitMention?: boolean;
|
||||
eventMessage?: ZaloEventMessage;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
@@ -66,12 +49,6 @@ export type ZaloSendResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroupContext = {
|
||||
groupId: string;
|
||||
name?: string;
|
||||
members?: string[];
|
||||
};
|
||||
|
||||
export type ZaloAuthStatus = {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
@@ -82,7 +59,6 @@ type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
|
||||
type ZalouserGroupConfig = {
|
||||
allow?: boolean;
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
tools?: ZalouserToolConfig;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
LoginQRCallbackEventType,
|
||||
Reactions,
|
||||
ThreadType,
|
||||
Zalo,
|
||||
type API,
|
||||
@@ -19,8 +18,6 @@ import {
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import type {
|
||||
ZaloAuthStatus,
|
||||
ZaloEventMessage,
|
||||
ZaloGroupContext,
|
||||
ZaloGroup,
|
||||
ZaloGroupMember,
|
||||
ZaloInboundMessage,
|
||||
@@ -35,7 +32,6 @@ const QR_LOGIN_TTL_MS = 3 * 60_000;
|
||||
const DEFAULT_QR_START_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000;
|
||||
const GROUP_INFO_CHUNK_SIZE = 80;
|
||||
const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
const apiByProfile = new Map<string, API>();
|
||||
const apiInitByProfile = new Map<string, Promise<API>>();
|
||||
@@ -60,11 +56,6 @@ type ActiveZaloListener = {
|
||||
};
|
||||
|
||||
const activeListeners = new Map<string, ActiveZaloListener>();
|
||||
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
|
||||
|
||||
type ApiTypingCapability = {
|
||||
sendTypingEvent: (threadId: string, type?: ThreadType) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type StoredZaloCredentials = {
|
||||
imei: string;
|
||||
@@ -141,27 +132,6 @@ function toNumberId(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function toStringValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function toInteger(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeMessageContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -195,79 +165,6 @@ function resolveInboundTimestamp(rawTs: unknown): number {
|
||||
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
|
||||
}
|
||||
|
||||
function extractMentionIds(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return "";
|
||||
}
|
||||
return toNumberId((entry as { uid?: unknown }).uid);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveGroupNameFromMessageData(data: Record<string, unknown>): string | undefined {
|
||||
const candidates = [data.groupName, data.gName, data.idToName, data.threadName, data.roomName];
|
||||
for (const candidate of candidates) {
|
||||
const value = toStringValue(candidate);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildEventMessage(data: Record<string, unknown>): ZaloEventMessage | undefined {
|
||||
const msgId = toStringValue(data.msgId);
|
||||
const cliMsgId = toStringValue(data.cliMsgId);
|
||||
const uidFrom = toStringValue(data.uidFrom);
|
||||
const idTo = toStringValue(data.idTo);
|
||||
if (!msgId || !cliMsgId || !uidFrom || !idTo) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
msgId,
|
||||
cliMsgId,
|
||||
uidFrom,
|
||||
idTo,
|
||||
msgType: toStringValue(data.msgType) || "webchat",
|
||||
st: toInteger(data.st, 0),
|
||||
at: toInteger(data.at, 0),
|
||||
cmd: toInteger(data.cmd, 0),
|
||||
ts: toStringValue(data.ts) || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionIcon(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "like" || trimmed === "👍" || trimmed === ":+1:") {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
if (lower === "heart" || trimmed === "❤️" || trimmed === "<3") {
|
||||
return Reactions.HEART;
|
||||
}
|
||||
if (lower === "haha" || lower === "laugh" || trimmed === "😂") {
|
||||
return Reactions.HAHA;
|
||||
}
|
||||
if (lower === "wow" || trimmed === "😮") {
|
||||
return Reactions.WOW;
|
||||
}
|
||||
if (lower === "cry" || trimmed === "😢") {
|
||||
return Reactions.CRY;
|
||||
}
|
||||
if (lower === "angry" || trimmed === "😡") {
|
||||
return Reactions.ANGRY;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractSendMessageId(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
@@ -525,61 +422,7 @@ async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, Gr
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeGroupContextCacheKey(profile: string, groupId: string): string {
|
||||
return `${profile}:${groupId}`;
|
||||
}
|
||||
|
||||
function readCachedGroupContext(profile: string, groupId: string): ZaloGroupContext | null {
|
||||
const key = makeGroupContextCacheKey(profile, groupId);
|
||||
const cached = groupContextCache.get(key);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
groupContextCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
function writeCachedGroupContext(profile: string, context: ZaloGroupContext): void {
|
||||
const key = makeGroupContextCacheKey(profile, context.groupId);
|
||||
groupContextCache.set(key, {
|
||||
value: context,
|
||||
expiresAt: Date.now() + GROUP_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function clearCachedGroupContext(profile: string): void {
|
||||
for (const key of groupContextCache.keys()) {
|
||||
if (key.startsWith(`${profile}:`)) {
|
||||
groupContextCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGroupMembersFromInfo(
|
||||
groupInfo: (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] }) | undefined,
|
||||
): string[] | undefined {
|
||||
if (!groupInfo || !Array.isArray(groupInfo.currentMems)) {
|
||||
return undefined;
|
||||
}
|
||||
const members = groupInfo.currentMems
|
||||
.map((member) => {
|
||||
if (!member || typeof member !== "object") {
|
||||
return "";
|
||||
}
|
||||
const record = member as { dName?: unknown; zaloName?: unknown };
|
||||
return toStringValue(record.dName) || toStringValue(record.zaloName);
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (members.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
|
||||
function toInboundMessage(message: Message): ZaloInboundMessage | null {
|
||||
const data = message.data as Record<string, unknown>;
|
||||
const isGroup = message.type === ThreadType.Group;
|
||||
const senderId = toNumberId(data.uidFrom);
|
||||
@@ -590,36 +433,15 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
|
||||
return null;
|
||||
}
|
||||
const content = normalizeMessageContent(data.content);
|
||||
const normalizedOwnUserId = toNumberId(ownUserId);
|
||||
const mentionIds = extractMentionIds(data.mentions);
|
||||
const quoteOwnerId =
|
||||
data.quote && typeof data.quote === "object"
|
||||
? toNumberId((data.quote as { ownerId?: unknown }).ownerId)
|
||||
: "";
|
||||
const hasAnyMention = mentionIds.length > 0;
|
||||
const canResolveExplicitMention = Boolean(normalizedOwnUserId);
|
||||
const wasExplicitlyMentioned = Boolean(
|
||||
normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
|
||||
);
|
||||
const implicitMention = Boolean(
|
||||
normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
|
||||
);
|
||||
const eventMessage = buildEventMessage(data);
|
||||
return {
|
||||
threadId,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined,
|
||||
groupName: isGroup ? resolveGroupNameFromMessageData(data) : undefined,
|
||||
content,
|
||||
timestampMs: resolveInboundTimestamp(data.ts),
|
||||
msgId: typeof data.msgId === "string" ? data.msgId : undefined,
|
||||
cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
|
||||
hasAnyMention,
|
||||
canResolveExplicitMention,
|
||||
wasExplicitlyMentioned,
|
||||
implicitMention,
|
||||
eventMessage,
|
||||
raw: message,
|
||||
};
|
||||
}
|
||||
@@ -796,34 +618,6 @@ export async function listZaloGroupMembers(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveZaloGroupContext(
|
||||
profileInput: string | null | undefined,
|
||||
groupId: string,
|
||||
): Promise<ZaloGroupContext> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
const normalizedGroupId = toNumberId(groupId) || groupId.trim();
|
||||
if (!normalizedGroupId) {
|
||||
throw new Error("groupId is required");
|
||||
}
|
||||
const cached = readCachedGroupContext(profile, normalizedGroupId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const api = await ensureApi(profile);
|
||||
const response = await api.getGroupInfo(normalizedGroupId);
|
||||
const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
|
||||
| (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
|
||||
| undefined;
|
||||
const context: ZaloGroupContext = {
|
||||
groupId: normalizedGroupId,
|
||||
name: groupInfo?.name?.trim() || undefined,
|
||||
members: extractGroupMembersFromInfo(groupInfo),
|
||||
};
|
||||
writeCachedGroupContext(profile, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function sendZaloTextMessage(
|
||||
threadId: string,
|
||||
text: string,
|
||||
@@ -876,84 +670,6 @@ export async function sendZaloTextMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendZaloTypingEvent(
|
||||
threadId: string,
|
||||
options: Pick<ZaloSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
const profile = normalizeProfile(options.profile);
|
||||
const trimmedThreadId = threadId.trim();
|
||||
if (!trimmedThreadId) {
|
||||
throw new Error("No threadId provided");
|
||||
}
|
||||
const api = await ensureApi(profile);
|
||||
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
if ("sendTypingEvent" in api && typeof api.sendTypingEvent === "function") {
|
||||
await (api as API & ApiTypingCapability).sendTypingEvent(trimmedThreadId, type);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOwnUserId(api: API): Promise<string> {
|
||||
const info = await api.fetchAccountInfo();
|
||||
const profile = "profile" in info ? info.profile : info;
|
||||
return toNumberId(profile.userId);
|
||||
}
|
||||
|
||||
export async function sendZaloReaction(params: {
|
||||
profile?: string | null;
|
||||
threadId: string;
|
||||
isGroup?: boolean;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
}): Promise<{ ok: boolean; error?: string }> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const threadId = params.threadId.trim();
|
||||
const msgId = toStringValue(params.msgId);
|
||||
const cliMsgId = toStringValue(params.cliMsgId);
|
||||
if (!threadId || !msgId || !cliMsgId) {
|
||||
return { ok: false, error: "threadId, msgId, and cliMsgId are required" };
|
||||
}
|
||||
try {
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
const icon = params.remove
|
||||
? { rType: -1, source: 6, icon: "" }
|
||||
: normalizeReactionIcon(params.emoji);
|
||||
await api.addReaction(icon, {
|
||||
data: { msgId, cliMsgId },
|
||||
threadId,
|
||||
type,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: toErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendZaloDeliveredEvent(params: {
|
||||
profile?: string | null;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
|
||||
}
|
||||
|
||||
export async function sendZaloSeenEvent(params: {
|
||||
profile?: string | null;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
await api.sendSeenEvent(params.message, type);
|
||||
}
|
||||
|
||||
export async function sendZaloLink(
|
||||
threadId: string,
|
||||
url: string,
|
||||
@@ -1202,7 +918,6 @@ export async function logoutZaloProfile(profileInput?: string | null): Promise<{
|
||||
}> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
resetQrLogin(profile);
|
||||
clearCachedGroupContext(profile);
|
||||
|
||||
const listener = activeListeners.get(profile);
|
||||
if (listener) {
|
||||
@@ -1241,7 +956,6 @@ export async function startZaloListener(params: {
|
||||
}
|
||||
|
||||
const api = await ensureApi(profile);
|
||||
const ownUserId = await resolveOwnUserId(api);
|
||||
let stopped = false;
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -1268,7 +982,7 @@ export async function startZaloListener(params: {
|
||||
if (incoming.isSelf) {
|
||||
return;
|
||||
}
|
||||
const normalized = toInboundMessage(incoming, ownUserId);
|
||||
const normalized = toInboundMessage(incoming);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -1389,7 +1103,6 @@ export async function resolveZaloAllowFromEntries(params: {
|
||||
export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise<void> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
resetQrLogin(profile);
|
||||
clearCachedGroupContext(profile);
|
||||
const listener = activeListeners.get(profile);
|
||||
if (listener) {
|
||||
listener.stop();
|
||||
|
||||
52
extensions/zalouser/src/zca-js-exports.d.ts
vendored
52
extensions/zalouser/src/zca-js-exports.d.ts
vendored
@@ -4,18 +4,6 @@ declare module "zca-js" {
|
||||
Group = 1,
|
||||
}
|
||||
|
||||
export enum Reactions {
|
||||
HEART = "/-heart",
|
||||
LIKE = "/-strong",
|
||||
HAHA = ":>",
|
||||
WOW = ":o",
|
||||
CRY = ":-((",
|
||||
ANGRY = ":-h",
|
||||
KISS = ":-*",
|
||||
TEARS_OF_JOY = ":')",
|
||||
NONE = "",
|
||||
}
|
||||
|
||||
export enum LoginQRCallbackEventType {
|
||||
QRCodeGenerated = 0,
|
||||
QRCodeExpired = 1,
|
||||
@@ -122,27 +110,6 @@ declare module "zca-js" {
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
export type ZaloEventMessageParams = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type AddReactionDestination = {
|
||||
data: {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
};
|
||||
threadId: string;
|
||||
type: ThreadType;
|
||||
};
|
||||
|
||||
export class API {
|
||||
listener: Listener;
|
||||
getContext(): {
|
||||
@@ -157,7 +124,6 @@ declare module "zca-js" {
|
||||
};
|
||||
fetchAccountInfo(): Promise<{ profile: User } | User>;
|
||||
getAllFriends(): Promise<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
@@ -188,24 +154,6 @@ declare module "zca-js" {
|
||||
threadId: string,
|
||||
type?: ThreadType,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendTypingEvent(
|
||||
threadId: string,
|
||||
type?: ThreadType,
|
||||
destType?: number,
|
||||
): Promise<{ status: number }>;
|
||||
addReaction(
|
||||
icon: Reactions | string | { rType: number; source: number; icon: string },
|
||||
dest: AddReactionDestination,
|
||||
): Promise<unknown>;
|
||||
sendDeliveredEvent(
|
||||
isSeen: boolean,
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
sendSeenEvent(
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class Zalo {
|
||||
|
||||
@@ -44,10 +44,6 @@
|
||||
"types": "./dist/plugin-sdk/account-id.d.ts",
|
||||
"default": "./dist/plugin-sdk/account-id.js"
|
||||
},
|
||||
"./plugin-sdk/keyed-async-queue": {
|
||||
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
||||
"default": "./dist/plugin-sdk/keyed-async-queue.js"
|
||||
},
|
||||
"./cli-entry": "./openclaw.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verifies that critical plugin-sdk exports are present in the compiled dist output.
|
||||
* Regression guard for #27569 where isDangerousNameMatchingEnabled was missing
|
||||
* from the compiled output, breaking channel extension plugins at runtime.
|
||||
*
|
||||
* Run after `pnpm build` to catch missing exports before release.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const distFile = resolve(__dirname, "..", "dist", "plugin-sdk", "index.js");
|
||||
|
||||
if (!existsSync(distFile)) {
|
||||
console.error("ERROR: dist/plugin-sdk/index.js not found. Run `pnpm build` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(distFile, "utf-8");
|
||||
|
||||
// Extract the final export statement from the compiled output.
|
||||
// tsdown/rolldown emits a single `export { ... }` at the end of the file.
|
||||
const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
|
||||
if (!exportMatch) {
|
||||
console.error("ERROR: Could not find export statement in dist/plugin-sdk/index.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const exportedNames = exportMatch[1]
|
||||
.split(",")
|
||||
.map((s) => {
|
||||
// Handle `foo as bar` aliases — the exported name is the `bar` part
|
||||
const parts = s.trim().split(/\s+as\s+/);
|
||||
return (parts[parts.length - 1] || "").trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const exportSet = new Set(exportedNames);
|
||||
|
||||
// Critical functions that channel extension plugins import from openclaw/plugin-sdk.
|
||||
// If any of these are missing, plugins will fail at runtime with:
|
||||
// TypeError: (0 , _pluginSdk.<name>) is not a function
|
||||
const requiredExports = [
|
||||
"isDangerousNameMatchingEnabled",
|
||||
"createAccountListHelpers",
|
||||
"buildAgentMediaPayload",
|
||||
"createReplyPrefixOptions",
|
||||
"createTypingCallbacks",
|
||||
"logInboundDrop",
|
||||
"logTypingFailure",
|
||||
"buildPendingHistoryContextFromMap",
|
||||
"clearHistoryEntriesIfEnabled",
|
||||
"recordPendingHistoryEntryIfEnabled",
|
||||
"resolveControlCommandGate",
|
||||
"resolveDmGroupAccessWithLists",
|
||||
"resolveAllowlistProviderRuntimeGroupPolicy",
|
||||
"resolveDefaultGroupPolicy",
|
||||
"resolveChannelMediaMaxBytes",
|
||||
"warnMissingProviderGroupPolicyFallbackOnce",
|
||||
"emptyPluginConfigSchema",
|
||||
"normalizePluginHttpPath",
|
||||
"registerPluginHttpRoute",
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
"DEFAULT_GROUP_HISTORY_LIMIT",
|
||||
];
|
||||
|
||||
let missing = 0;
|
||||
for (const name of requiredExports) {
|
||||
if (!exportSet.has(name)) {
|
||||
console.error(`MISSING EXPORT: ${name}`);
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing > 0) {
|
||||
console.error(`\nERROR: ${missing} required export(s) missing from dist/plugin-sdk/index.js.`);
|
||||
console.error("This will break channel extension plugins at runtime.");
|
||||
console.error("Check src/plugin-sdk/index.ts and rebuild.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`OK: All ${requiredExports.length} required plugin-sdk exports verified.`);
|
||||
@@ -182,12 +182,6 @@ type LoadedState = {
|
||||
};
|
||||
|
||||
type LabelTarget = "issue" | "pr";
|
||||
type LabelItemBatch = {
|
||||
batchIndex: number;
|
||||
items: LabelItem[];
|
||||
totalCount: number;
|
||||
fetchedCount: number;
|
||||
};
|
||||
|
||||
function parseArgs(argv: string[]): ScriptOptions {
|
||||
let limit = Number.POSITIVE_INFINITY;
|
||||
@@ -414,22 +408,9 @@ function fetchPullRequestPage(repo: RepoInfo, after: string | null): PullRequest
|
||||
return pullRequests;
|
||||
}
|
||||
|
||||
function mapNodeToLabelItem(node: IssuePage["nodes"][number]): LabelItem {
|
||||
return {
|
||||
number: node.number,
|
||||
title: node.title,
|
||||
body: node.body ?? "",
|
||||
labels: node.labels?.nodes ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function* fetchOpenLabelItemBatches(params: {
|
||||
limit: number;
|
||||
kindPlural: "issues" | "pull requests";
|
||||
fetchPage: (repo: RepoInfo, after: string | null) => IssuePage | PullRequestPage;
|
||||
}): Generator<LabelItemBatch> {
|
||||
function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
|
||||
const repo = resolveRepo();
|
||||
const results: LabelItem[] = [];
|
||||
const results: Issue[] = [];
|
||||
let page = 1;
|
||||
let after: string | null = null;
|
||||
let totalCount = 0;
|
||||
@@ -438,28 +419,33 @@ function* fetchOpenLabelItemBatches(params: {
|
||||
|
||||
logStep(`Repository: ${repo.owner}/${repo.name}`);
|
||||
|
||||
while (fetchedCount < params.limit) {
|
||||
const pageData = params.fetchPage(repo, after);
|
||||
while (fetchedCount < limit) {
|
||||
const pageData = fetchIssuePage(repo, after);
|
||||
const nodes = pageData.nodes ?? [];
|
||||
totalCount = pageData.totalCount ?? totalCount;
|
||||
|
||||
if (page === 1) {
|
||||
logSuccess(`Found ${totalCount} open ${params.kindPlural}.`);
|
||||
logSuccess(`Found ${totalCount} open issues.`);
|
||||
}
|
||||
|
||||
logInfo(`Fetched page ${page} (${nodes.length} ${params.kindPlural}).`);
|
||||
logInfo(`Fetched page ${page} (${nodes.length} issues).`);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (fetchedCount >= params.limit) {
|
||||
if (fetchedCount >= limit) {
|
||||
break;
|
||||
}
|
||||
results.push(mapNodeToLabelItem(node));
|
||||
results.push({
|
||||
number: node.number,
|
||||
title: node.title,
|
||||
body: node.body ?? "",
|
||||
labels: node.labels?.nodes ?? [],
|
||||
});
|
||||
fetchedCount += 1;
|
||||
|
||||
if (results.length >= WORK_BATCH_SIZE) {
|
||||
yield {
|
||||
batchIndex,
|
||||
items: results.splice(0, results.length),
|
||||
issues: results.splice(0, results.length),
|
||||
totalCount,
|
||||
fetchedCount,
|
||||
};
|
||||
@@ -478,39 +464,72 @@ function* fetchOpenLabelItemBatches(params: {
|
||||
if (results.length) {
|
||||
yield {
|
||||
batchIndex,
|
||||
items: results,
|
||||
issues: results,
|
||||
totalCount,
|
||||
fetchedCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function* fetchOpenIssueBatches(limit: number): Generator<IssueBatch> {
|
||||
for (const batch of fetchOpenLabelItemBatches({
|
||||
limit,
|
||||
kindPlural: "issues",
|
||||
fetchPage: fetchIssuePage,
|
||||
})) {
|
||||
yield {
|
||||
batchIndex: batch.batchIndex,
|
||||
issues: batch.items,
|
||||
totalCount: batch.totalCount,
|
||||
fetchedCount: batch.fetchedCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function* fetchOpenPullRequestBatches(limit: number): Generator<PullRequestBatch> {
|
||||
for (const batch of fetchOpenLabelItemBatches({
|
||||
limit,
|
||||
kindPlural: "pull requests",
|
||||
fetchPage: fetchPullRequestPage,
|
||||
})) {
|
||||
const repo = resolveRepo();
|
||||
const results: PullRequest[] = [];
|
||||
let page = 1;
|
||||
let after: string | null = null;
|
||||
let totalCount = 0;
|
||||
let fetchedCount = 0;
|
||||
let batchIndex = 1;
|
||||
|
||||
logStep(`Repository: ${repo.owner}/${repo.name}`);
|
||||
|
||||
while (fetchedCount < limit) {
|
||||
const pageData = fetchPullRequestPage(repo, after);
|
||||
const nodes = pageData.nodes ?? [];
|
||||
totalCount = pageData.totalCount ?? totalCount;
|
||||
|
||||
if (page === 1) {
|
||||
logSuccess(`Found ${totalCount} open pull requests.`);
|
||||
}
|
||||
|
||||
logInfo(`Fetched page ${page} (${nodes.length} pull requests).`);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (fetchedCount >= limit) {
|
||||
break;
|
||||
}
|
||||
results.push({
|
||||
number: node.number,
|
||||
title: node.title,
|
||||
body: node.body ?? "",
|
||||
labels: node.labels?.nodes ?? [],
|
||||
});
|
||||
fetchedCount += 1;
|
||||
|
||||
if (results.length >= WORK_BATCH_SIZE) {
|
||||
yield {
|
||||
batchIndex,
|
||||
pullRequests: results.splice(0, results.length),
|
||||
totalCount,
|
||||
fetchedCount,
|
||||
};
|
||||
batchIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pageData.pageInfo.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
after = pageData.pageInfo.endCursor ?? null;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (results.length) {
|
||||
yield {
|
||||
batchIndex: batch.batchIndex,
|
||||
pullRequests: batch.items,
|
||||
totalCount: batch.totalCount,
|
||||
fetchedCount: batch.fetchedCount,
|
||||
batchIndex,
|
||||
pullRequests: results,
|
||||
totalCount,
|
||||
fetchedCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,71 +169,9 @@ function checkAppcastSparkleVersions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Critical functions that channel extension plugins import from openclaw/plugin-sdk.
|
||||
// If any are missing from the compiled output, plugins crash at runtime (#27569).
|
||||
const requiredPluginSdkExports = [
|
||||
"isDangerousNameMatchingEnabled",
|
||||
"createAccountListHelpers",
|
||||
"buildAgentMediaPayload",
|
||||
"createReplyPrefixOptions",
|
||||
"createTypingCallbacks",
|
||||
"logInboundDrop",
|
||||
"logTypingFailure",
|
||||
"buildPendingHistoryContextFromMap",
|
||||
"clearHistoryEntriesIfEnabled",
|
||||
"recordPendingHistoryEntryIfEnabled",
|
||||
"resolveControlCommandGate",
|
||||
"resolveDmGroupAccessWithLists",
|
||||
"resolveAllowlistProviderRuntimeGroupPolicy",
|
||||
"resolveDefaultGroupPolicy",
|
||||
"resolveChannelMediaMaxBytes",
|
||||
"warnMissingProviderGroupPolicyFallbackOnce",
|
||||
"emptyPluginConfigSchema",
|
||||
"normalizePluginHttpPath",
|
||||
"registerPluginHttpRoute",
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
"DEFAULT_GROUP_HISTORY_LIMIT",
|
||||
];
|
||||
|
||||
function checkPluginSdkExports() {
|
||||
const distPath = resolve("dist", "plugin-sdk", "index.js");
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(distPath, "utf8");
|
||||
} catch {
|
||||
console.error("release-check: dist/plugin-sdk/index.js not found (build missing?).");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const exportMatch = content.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
|
||||
if (!exportMatch) {
|
||||
console.error("release-check: could not find export statement in dist/plugin-sdk/index.js.");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const exportedNames = new Set(
|
||||
exportMatch[1].split(",").map((s) => {
|
||||
const parts = s.trim().split(/\s+as\s+/);
|
||||
return (parts[parts.length - 1] || "").trim();
|
||||
}),
|
||||
);
|
||||
|
||||
const missingExports = requiredPluginSdkExports.filter((name) => !exportedNames.has(name));
|
||||
if (missingExports.length > 0) {
|
||||
console.error("release-check: missing critical plugin-sdk exports (#27569):");
|
||||
for (const name of missingExports) {
|
||||
console.error(` - ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
checkPluginVersions();
|
||||
checkAppcastSparkleVersions();
|
||||
checkPluginSdkExports();
|
||||
|
||||
const results = runPackDry();
|
||||
const files = results.flatMap((entry) => entry.files ?? []);
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dedupe_chrome_args() {
|
||||
local -A seen_args=()
|
||||
local -a unique_args=()
|
||||
|
||||
for arg in "${CHROME_ARGS[@]}"; do
|
||||
if [[ -n "${seen_args["$arg"]:+x}" ]]; then
|
||||
continue
|
||||
fi
|
||||
seen_args["$arg"]=1
|
||||
unique_args+=("$arg")
|
||||
done
|
||||
|
||||
CHROME_ARGS=("${unique_args[@]}")
|
||||
}
|
||||
|
||||
export DISPLAY=:1
|
||||
export HOME=/tmp/openclaw-home
|
||||
export XDG_CONFIG_HOME="${HOME}/.config"
|
||||
@@ -29,9 +14,6 @@ ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-
|
||||
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}"
|
||||
ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-0}}"
|
||||
NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-${CLAWDBOT_BROWSER_NOVNC_PASSWORD:-}}"
|
||||
DISABLE_GRAPHICS_FLAGS="${OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS:-1}"
|
||||
DISABLE_EXTENSIONS="${OPENCLAW_BROWSER_DISABLE_EXTENSIONS:-1}"
|
||||
RENDERER_PROCESS_LIMIT="${OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT:-2}"
|
||||
|
||||
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
|
||||
|
||||
@@ -40,6 +22,7 @@ Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
|
||||
if [[ "${HEADLESS}" == "1" ]]; then
|
||||
CHROME_ARGS=(
|
||||
"--headless=new"
|
||||
"--disable-gpu"
|
||||
)
|
||||
else
|
||||
CHROME_ARGS=()
|
||||
@@ -62,30 +45,9 @@ CHROME_ARGS+=(
|
||||
"--disable-features=TranslateUI"
|
||||
"--disable-breakpad"
|
||||
"--disable-crash-reporter"
|
||||
"--no-zygote"
|
||||
"--metrics-recording-only"
|
||||
)
|
||||
|
||||
DISABLE_GRAPHICS_FLAGS_LOWER="${DISABLE_GRAPHICS_FLAGS,,}"
|
||||
if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "true" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "yes" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "on" ]]; then
|
||||
CHROME_ARGS+=(
|
||||
"--disable-3d-apis"
|
||||
"--disable-gpu"
|
||||
"--disable-software-rasterizer"
|
||||
)
|
||||
fi
|
||||
|
||||
DISABLE_EXTENSIONS_LOWER="${DISABLE_EXTENSIONS,,}"
|
||||
if [[ "${DISABLE_EXTENSIONS_LOWER}" == "1" || "${DISABLE_EXTENSIONS_LOWER}" == "true" || "${DISABLE_EXTENSIONS_LOWER}" == "yes" || "${DISABLE_EXTENSIONS_LOWER}" == "on" ]]; then
|
||||
CHROME_ARGS+=(
|
||||
"--disable-extensions"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "${RENDERER_PROCESS_LIMIT}" =~ ^[0-9]+$ && "${RENDERER_PROCESS_LIMIT}" -gt 0 ]]; then
|
||||
CHROME_ARGS+=("--renderer-process-limit=${RENDERER_PROCESS_LIMIT}")
|
||||
fi
|
||||
|
||||
if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
|
||||
CHROME_ARGS+=(
|
||||
"--no-sandbox"
|
||||
@@ -93,7 +55,6 @@ if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then
|
||||
)
|
||||
fi
|
||||
|
||||
dedupe_chrome_args
|
||||
chromium "${CHROME_ARGS[@]}" about:blank &
|
||||
|
||||
for _ in $(seq 1 50); do
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||
import {
|
||||
resolveAcpClientSpawnEnv,
|
||||
resolveAcpClientSpawnInvocation,
|
||||
@@ -35,11 +35,22 @@ function makePermissionRequest(
|
||||
};
|
||||
}
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
const createTempDir = () => tempDirs.make("openclaw-acp-client-test-");
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-acp-client-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await tempDirs.cleanup();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveAcpClientSpawnEnv", () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
|
||||
export class SessionActorQueue {
|
||||
private readonly queue = new KeyedAsyncQueue();
|
||||
private readonly tailBySession = new Map<string, Promise<void>>();
|
||||
private readonly pendingBySession = new Map<string, number>();
|
||||
|
||||
getTailMapForTesting(): Map<string, Promise<void>> {
|
||||
return this.queue.getTailMapForTesting();
|
||||
return this.tailBySession;
|
||||
}
|
||||
|
||||
getTotalPendingCount(): number {
|
||||
@@ -21,18 +19,35 @@ export class SessionActorQueue {
|
||||
}
|
||||
|
||||
async run<T>(actorKey: string, op: () => Promise<T>): Promise<T> {
|
||||
return this.queue.enqueue(actorKey, op, {
|
||||
onEnqueue: () => {
|
||||
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
|
||||
},
|
||||
onSettle: () => {
|
||||
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
|
||||
if (pending <= 0) {
|
||||
this.pendingBySession.delete(actorKey);
|
||||
} else {
|
||||
this.pendingBySession.set(actorKey, pending);
|
||||
}
|
||||
},
|
||||
const previous = this.tailBySession.get(actorKey) ?? Promise.resolve();
|
||||
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
|
||||
let release: () => void = () => {};
|
||||
const marker = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const queuedTail = previous
|
||||
.catch(() => {
|
||||
// Keep actor queue alive after an operation failure.
|
||||
})
|
||||
.then(() => marker);
|
||||
this.tailBySession.set(actorKey, queuedTail);
|
||||
|
||||
await previous.catch(() => {
|
||||
// Previous failures should not block newer commands.
|
||||
});
|
||||
try {
|
||||
return await op();
|
||||
} finally {
|
||||
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
|
||||
if (pending <= 0) {
|
||||
this.pendingBySession.delete(actorKey);
|
||||
} else {
|
||||
this.pendingBySession.set(actorKey, pending);
|
||||
}
|
||||
release();
|
||||
if (this.tailBySession.get(actorKey) === queuedTail) {
|
||||
this.tailBySession.delete(actorKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export type AcpRuntimeAdapterContractParams = {
|
||||
agentId?: string;
|
||||
successPrompt?: string;
|
||||
errorPrompt?: string;
|
||||
includeControlChecks?: boolean;
|
||||
assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise<void>;
|
||||
assertErrorOutcome?: (params: {
|
||||
events: AcpRuntimeEvent[];
|
||||
@@ -52,25 +51,23 @@ export async function runAcpRuntimeAdapterContract(
|
||||
).toBe(true);
|
||||
await params.assertSuccessEvents?.(successEvents);
|
||||
|
||||
if (params.includeControlChecks ?? true) {
|
||||
if (runtime.getStatus) {
|
||||
const status = await runtime.getStatus({ handle });
|
||||
expect(status).toBeDefined();
|
||||
expect(typeof status).toBe("object");
|
||||
}
|
||||
if (runtime.setMode) {
|
||||
await runtime.setMode({
|
||||
handle,
|
||||
mode: "contract",
|
||||
});
|
||||
}
|
||||
if (runtime.setConfigOption) {
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "contract_key",
|
||||
value: "contract_value",
|
||||
});
|
||||
}
|
||||
if (runtime.getStatus) {
|
||||
const status = await runtime.getStatus({ handle });
|
||||
expect(status).toBeDefined();
|
||||
expect(typeof status).toBe("object");
|
||||
}
|
||||
if (runtime.setMode) {
|
||||
await runtime.setMode({
|
||||
handle,
|
||||
mode: "contract",
|
||||
});
|
||||
}
|
||||
if (runtime.setConfigOption) {
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "contract_key",
|
||||
value: "contract_value",
|
||||
});
|
||||
}
|
||||
|
||||
let errorThrown: unknown = null;
|
||||
|
||||
@@ -150,9 +150,17 @@ export class AcpGatewayAgent implements Agent {
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const meta = parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKeyFromMeta({
|
||||
const sessionKey = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: `acp:${sessionId}`,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
await resetSessionIfNeeded({
|
||||
meta,
|
||||
sessionKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
|
||||
const session = this.sessionStore.createSession({
|
||||
@@ -174,9 +182,17 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
|
||||
const meta = parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKeyFromMeta({
|
||||
const sessionKey = await resolveSessionKey({
|
||||
meta,
|
||||
fallbackKey: params.sessionId,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
await resetSessionIfNeeded({
|
||||
meta,
|
||||
sessionKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
|
||||
const session = this.sessionStore.createSession({
|
||||
@@ -312,25 +328,6 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSessionKeyFromMeta(params: {
|
||||
meta: ReturnType<typeof parseSessionMeta>;
|
||||
fallbackKey: string;
|
||||
}): Promise<string> {
|
||||
const sessionKey = await resolveSessionKey({
|
||||
meta: params.meta,
|
||||
fallbackKey: params.fallbackKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
await resetSessionIfNeeded({
|
||||
meta: params.meta,
|
||||
sessionKey,
|
||||
gateway: this.gateway,
|
||||
opts: this.opts,
|
||||
});
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
private async handleAgentEvent(evt: EventFrame): Promise<void> {
|
||||
const payload = evt.payload as Record<string, unknown> | undefined;
|
||||
if (!payload) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAuthProfileOrder } from "./order.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
it("accepts base-provider credentials for volcengine-plan auth lookup", () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"volcengine:default": {
|
||||
type: "api_key",
|
||||
provider: "volcengine",
|
||||
key: "sk-test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "volcengine-plan",
|
||||
});
|
||||
|
||||
expect(order).toEqual(["volcengine:default"]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
findNormalizedProviderValue,
|
||||
normalizeProviderId,
|
||||
normalizeProviderIdForAuth,
|
||||
} from "../model-selection.js";
|
||||
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
|
||||
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import {
|
||||
@@ -20,7 +16,6 @@ export function resolveAuthProfileOrder(params: {
|
||||
}): string[] {
|
||||
const { cfg, store, provider, preferredProfile } = params;
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
const providerAuthKey = normalizeProviderIdForAuth(provider);
|
||||
const now = Date.now();
|
||||
|
||||
// Clear any cooldowns that have expired since the last check so profiles
|
||||
@@ -32,12 +27,12 @@ export function resolveAuthProfileOrder(params: {
|
||||
const explicitOrder = storedOrder ?? configuredOrder;
|
||||
const explicitProfiles = cfg?.auth?.profiles
|
||||
? Object.entries(cfg.auth.profiles)
|
||||
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === providerAuthKey)
|
||||
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
|
||||
.map(([profileId]) => profileId)
|
||||
: [];
|
||||
const baseOrder =
|
||||
explicitOrder ??
|
||||
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider));
|
||||
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
|
||||
if (baseOrder.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,12 +42,12 @@ export function resolveAuthProfileOrder(params: {
|
||||
if (!cred) {
|
||||
return false;
|
||||
}
|
||||
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
|
||||
if (normalizeProviderId(cred.provider) !== providerKey) {
|
||||
return false;
|
||||
}
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig) {
|
||||
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
|
||||
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
|
||||
return false;
|
||||
}
|
||||
if (profileConfig.mode !== cred.type) {
|
||||
@@ -91,7 +86,7 @@ export function resolveAuthProfileOrder(params: {
|
||||
// provider's stored credentials and use any valid entries.
|
||||
const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]);
|
||||
if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) {
|
||||
const storeProfiles = listProfilesForProvider(store, provider);
|
||||
const storeProfiles = listProfilesForProvider(store, providerKey);
|
||||
filtered = storeProfiles.filter(isValidProfile);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,9 +241,16 @@ export async function markAuthProfileUsed(params: {
|
||||
if (!freshStore.profiles[profileId]) {
|
||||
return false;
|
||||
}
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) =>
|
||||
resetUsageStats(existing, { lastUsed: Date.now() }),
|
||||
);
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
lastUsed: Date.now(),
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -255,9 +262,16 @@ export async function markAuthProfileUsed(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
updateUsageStatsEntry(store, profileId, (existing) =>
|
||||
resetUsageStats(existing, { lastUsed: Date.now() }),
|
||||
);
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
store.usageStats[profileId] = {
|
||||
...store.usageStats[profileId],
|
||||
lastUsed: Date.now(),
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -346,30 +360,6 @@ export function resolveProfileUnusableUntilForDisplay(
|
||||
return resolveProfileUnusableUntil(stats);
|
||||
}
|
||||
|
||||
function resetUsageStats(
|
||||
existing: ProfileUsageStats | undefined,
|
||||
overrides?: Partial<ProfileUsageStats>,
|
||||
): ProfileUsageStats {
|
||||
return {
|
||||
...existing,
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function updateUsageStatsEntry(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
updater: (existing: ProfileUsageStats | undefined) => ProfileUsageStats,
|
||||
): void {
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
store.usageStats[profileId] = updater(store.usageStats[profileId]);
|
||||
}
|
||||
|
||||
function keepActiveWindowOrRecompute(params: {
|
||||
existingUntil: number | undefined;
|
||||
now: number;
|
||||
@@ -458,6 +448,9 @@ export async function markAuthProfileFailure(params: {
|
||||
if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) {
|
||||
return false;
|
||||
}
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
const existing = freshStore.usageStats[profileId] ?? {};
|
||||
|
||||
const now = Date.now();
|
||||
const providerKey = normalizeProviderId(profile.provider);
|
||||
const cfgResolved = resolveAuthCooldownConfig({
|
||||
@@ -465,14 +458,12 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
freshStore.usageStats[profileId] = computeNextProfileUsageStats({
|
||||
existing,
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -484,6 +475,8 @@ export async function markAuthProfileFailure(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
const existing = store.usageStats[profileId] ?? {};
|
||||
const now = Date.now();
|
||||
const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
|
||||
const cfgResolved = resolveAuthCooldownConfig({
|
||||
@@ -491,14 +484,12 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
updateUsageStatsEntry(store, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
store.usageStats[profileId] = computeNextProfileUsageStats({
|
||||
existing,
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -537,7 +528,14 @@ export async function clearAuthProfileCooldown(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) => resetUsageStats(existing));
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -549,6 +547,13 @@ export async function clearAuthProfileCooldown(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
updateUsageStatsEntry(store, profileId, (existing) => resetUsageStats(existing));
|
||||
store.usageStats[profileId] = {
|
||||
...store.usageStats[profileId],
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
disabledUntil: undefined,
|
||||
disabledReason: undefined,
|
||||
failureCounts: undefined,
|
||||
};
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
type ExecSecurity,
|
||||
buildEnforcedShellCommand,
|
||||
evaluateShellAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
recordAllowlistUse,
|
||||
requiresExecApproval,
|
||||
resolveAllowAlwaysPatterns,
|
||||
resolveExecApprovals,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
@@ -16,13 +19,10 @@ import { logInfo } from "../logger.js";
|
||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
@@ -67,12 +67,16 @@ export type ProcessGatewayAllowlistResult = {
|
||||
export async function processGatewayAllowlist(
|
||||
params: ProcessGatewayAllowlistParams,
|
||||
): Promise<ProcessGatewayAllowlistResult> {
|
||||
const { approvals, hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
|
||||
agentId: params.agentId,
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
host: "gateway",
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=gateway security=deny");
|
||||
}
|
||||
const allowlistEval = evaluateShellAllowlist({
|
||||
command: params.command,
|
||||
allowlist: approvals.allowlist,
|
||||
@@ -168,19 +172,20 @@ export async function processGatewayAllowlist(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
evaluateShellAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
requiresExecApproval,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
@@ -14,13 +17,10 @@ import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-cont
|
||||
import { logInfo } from "../logger.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
resolveRegisteredExecApprovalDecision,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
createApprovalSlug,
|
||||
@@ -56,12 +56,16 @@ export type ExecuteNodeHostCommandParams = {
|
||||
export async function executeNodeHostCommand(
|
||||
params: ExecuteNodeHostCommandParams,
|
||||
): Promise<AgentToolResult<ExecToolDetails>> {
|
||||
const { hostSecurity, hostAsk, askFallback } = resolveExecHostApprovalContext({
|
||||
agentId: params.agentId,
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
host: "node",
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=node security=deny");
|
||||
}
|
||||
if (params.boundNode && params.requestedNode && params.boundNode !== params.requestedNode) {
|
||||
throw new Error(`exec node not allowed (bound to ${params.boundNode})`);
|
||||
}
|
||||
@@ -239,16 +243,17 @@ export async function executeNodeHostCommand(
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
decision = await resolveRegisteredExecApprovalDecision({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
resolveExecApprovals,
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
|
||||
|
||||
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
|
||||
|
||||
export type ExecHostApprovalContext = {
|
||||
approvals: ResolvedExecApprovals;
|
||||
hostSecurity: ExecSecurity;
|
||||
hostAsk: ExecAsk;
|
||||
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
|
||||
};
|
||||
|
||||
export function resolveExecHostApprovalContext(params: {
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
ask: ExecAsk;
|
||||
host: "gateway" | "node";
|
||||
}): ExecHostApprovalContext {
|
||||
const approvals = resolveExecApprovals(params.agentId, {
|
||||
security: params.security,
|
||||
ask: params.ask,
|
||||
});
|
||||
const hostSecurity = minSecurity(params.security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error(`exec denied: host=${params.host} security=deny`);
|
||||
}
|
||||
return { approvals, hostSecurity, hostAsk, askFallback };
|
||||
}
|
||||
|
||||
export async function resolveApprovalDecisionOrUndefined(params: {
|
||||
approvalId: string;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
onFailure: () => void;
|
||||
}): Promise<string | null | undefined> {
|
||||
try {
|
||||
return await resolveRegisteredExecApprovalDecision({
|
||||
approvalId: params.approvalId,
|
||||
preResolvedDecision: params.preResolvedDecision,
|
||||
});
|
||||
} catch {
|
||||
params.onFailure();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSandboxWorkdir } from "./bash-tools.shared.js";
|
||||
|
||||
async function withTempDir(run: (dir: string) => Promise<void>) {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-"));
|
||||
try {
|
||||
await run(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveSandboxWorkdir", () => {
|
||||
it("maps container root workdir to host workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const warnings: string[] = [];
|
||||
const resolved = await resolveSandboxWorkdir({
|
||||
workdir: "/workspace",
|
||||
sandbox: {
|
||||
containerName: "sandbox-1",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
},
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(resolved.hostWorkdir).toBe(workspaceDir);
|
||||
expect(resolved.containerWorkdir).toBe("/workspace");
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps nested container workdir under the container workspace", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const nested = path.join(workspaceDir, "scripts", "runner");
|
||||
await mkdir(nested, { recursive: true });
|
||||
const warnings: string[] = [];
|
||||
const resolved = await resolveSandboxWorkdir({
|
||||
workdir: "/workspace/scripts/runner",
|
||||
sandbox: {
|
||||
containerName: "sandbox-2",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/workspace",
|
||||
},
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(resolved.hostWorkdir).toBe(nested);
|
||||
expect(resolved.containerWorkdir).toBe("/workspace/scripts/runner");
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("supports custom container workdir prefixes", async () => {
|
||||
await withTempDir(async (workspaceDir) => {
|
||||
const nested = path.join(workspaceDir, "project");
|
||||
await mkdir(nested, { recursive: true });
|
||||
const warnings: string[] = [];
|
||||
const resolved = await resolveSandboxWorkdir({
|
||||
workdir: "/sandbox-root/project",
|
||||
sandbox: {
|
||||
containerName: "sandbox-3",
|
||||
workspaceDir,
|
||||
containerWorkdir: "/sandbox-root",
|
||||
},
|
||||
warnings,
|
||||
});
|
||||
|
||||
expect(resolved.hostWorkdir).toBe(nested);
|
||||
expect(resolved.containerWorkdir).toBe("/sandbox-root/project");
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,14 +85,9 @@ export async function resolveSandboxWorkdir(params: {
|
||||
warnings: string[];
|
||||
}) {
|
||||
const fallback = params.sandbox.workspaceDir;
|
||||
const mappedHostWorkdir = mapContainerWorkdirToHost({
|
||||
workdir: params.workdir,
|
||||
sandbox: params.sandbox,
|
||||
});
|
||||
const candidateWorkdir = mappedHostWorkdir ?? params.workdir;
|
||||
try {
|
||||
const resolved = await assertSandboxPath({
|
||||
filePath: candidateWorkdir,
|
||||
filePath: params.workdir,
|
||||
cwd: process.cwd(),
|
||||
root: params.sandbox.workspaceDir,
|
||||
});
|
||||
@@ -118,36 +113,6 @@ export async function resolveSandboxWorkdir(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function mapContainerWorkdirToHost(params: {
|
||||
workdir: string;
|
||||
sandbox: BashSandboxConfig;
|
||||
}): string | undefined {
|
||||
const workdir = normalizeContainerPath(params.workdir);
|
||||
const containerRoot = normalizeContainerPath(params.sandbox.containerWorkdir);
|
||||
if (containerRoot === ".") {
|
||||
return undefined;
|
||||
}
|
||||
if (workdir === containerRoot) {
|
||||
return path.resolve(params.sandbox.workspaceDir);
|
||||
}
|
||||
if (!workdir.startsWith(`${containerRoot}/`)) {
|
||||
return undefined;
|
||||
}
|
||||
const rel = workdir
|
||||
.slice(containerRoot.length + 1)
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
return path.resolve(params.sandbox.workspaceDir, ...rel);
|
||||
}
|
||||
|
||||
function normalizeContainerPath(input: string): string {
|
||||
const normalized = input.trim().replace(/\\/g, "/");
|
||||
if (!normalized) {
|
||||
return ".";
|
||||
}
|
||||
return path.posix.normalize(normalized);
|
||||
}
|
||||
|
||||
export function resolveWorkdir(workdir: string, warnings: string[]) {
|
||||
const current = safeCwd();
|
||||
const fallback = current ?? homedir();
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
import { isRecord } from "../../utils.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
@@ -19,9 +18,20 @@ import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new KeyedAsyncQueue();
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
return CLI_RUN_QUEUE.enqueue(key, task);
|
||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||
const chained = prior.catch(() => undefined).then(task);
|
||||
// Keep queue continuity even when a run rejects, without emitting unhandled rejections.
|
||||
const tracked = chained
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (CLI_RUN_QUEUE.get(key) === tracked) {
|
||||
CLI_RUN_QUEUE.delete(key);
|
||||
}
|
||||
});
|
||||
CLI_RUN_QUEUE.set(key, tracked);
|
||||
return chained;
|
||||
}
|
||||
|
||||
type CliUsage = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readErrorName } from "../infra/errors.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
isAuthPermanentErrorMessage,
|
||||
@@ -83,6 +82,13 @@ function getStatusCode(err: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getErrorName(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
}
|
||||
return "name" in err ? String(err.name) : "";
|
||||
}
|
||||
|
||||
function getErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
@@ -121,7 +127,7 @@ function hasTimeoutHint(err: unknown): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
if (readErrorName(err) === "TimeoutError") {
|
||||
if (getErrorName(err) === "TimeoutError") {
|
||||
return true;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
@@ -135,7 +141,7 @@ export function isTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (readErrorName(err) !== "AbortError") {
|
||||
if (getErrorName(err) !== "AbortError") {
|
||||
return false;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { makeZeroUsageSnapshot } from "./usage.js";
|
||||
|
||||
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
@@ -40,7 +39,20 @@ describeLive("gemini live switch", () => {
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-antigravity",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
usage: makeZeroUsageSnapshot(),
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: now,
|
||||
},
|
||||
|
||||
@@ -109,62 +109,6 @@ type ModelFallbackRunResult<T> = {
|
||||
attempts: FallbackAttempt[];
|
||||
};
|
||||
|
||||
function buildFallbackSuccess<T>(params: {
|
||||
result: T;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: FallbackAttempt[];
|
||||
}): ModelFallbackRunResult<T> {
|
||||
return {
|
||||
result: params.result,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
attempts: params.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
async function runFallbackCandidate<T>(params: {
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
provider: string;
|
||||
model: string;
|
||||
}): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
result: await params.run(params.provider, params.model),
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) {
|
||||
throw err;
|
||||
}
|
||||
return { ok: false, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
async function runFallbackAttempt<T>(params: {
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: FallbackAttempt[];
|
||||
}): Promise<{ success: ModelFallbackRunResult<T> } | { error: unknown }> {
|
||||
const runResult = await runFallbackCandidate({
|
||||
run: params.run,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
});
|
||||
if (runResult.ok) {
|
||||
return {
|
||||
success: buildFallbackSuccess({
|
||||
result: runResult.result,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
attempts: params.attempts,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { error: runResult.error };
|
||||
}
|
||||
|
||||
function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean {
|
||||
return a.provider === b.provider && a.model === b.model;
|
||||
}
|
||||
@@ -500,12 +444,18 @@ export async function runWithModelFallback<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const attemptRun = await runFallbackAttempt({ run: params.run, ...candidate, attempts });
|
||||
if ("success" in attemptRun) {
|
||||
return attemptRun.success;
|
||||
}
|
||||
const err = attemptRun.error;
|
||||
{
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
result,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Context overflow errors should be handled by the inner runner's
|
||||
// compaction/retry logic, not by model fallback. If one escapes as a
|
||||
// throw, rethrow it immediately rather than trying a different model
|
||||
@@ -582,12 +532,18 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i];
|
||||
const attemptRun = await runFallbackAttempt({ run: params.run, ...candidate, attempts });
|
||||
if ("success" in attemptRun) {
|
||||
return attemptRun.success;
|
||||
}
|
||||
{
|
||||
const err = attemptRun.error;
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
result,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -13,40 +13,40 @@ import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
|
||||
installModelsConfigTestHooks({ restoreFetch: true });
|
||||
|
||||
async function writeAuthProfiles(agentDir: string, profiles: Record<string, unknown>) {
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify({ version: 1, profiles }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function expectBearerAuthHeader(fetchMock: { mock: { calls: unknown[][] } }, token: string) {
|
||||
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
|
||||
expect(opts?.headers?.Authorization).toBe(`Bearer ${token}`);
|
||||
}
|
||||
|
||||
describe("models-config", () => {
|
||||
it("uses the first github-copilot profile when env tokens are missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await withUnsetCopilotTokenEnv(async () => {
|
||||
const fetchMock = mockCopilotTokenExchangeSuccess();
|
||||
const agentDir = path.join(home, "agent-profiles");
|
||||
await writeAuthProfiles(agentDir, {
|
||||
"github-copilot:alpha": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "alpha-token",
|
||||
},
|
||||
"github-copilot:beta": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "beta-token",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:alpha": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "alpha-token",
|
||||
},
|
||||
"github-copilot:beta": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "beta-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
expectBearerAuthHeader(fetchMock, "alpha-token");
|
||||
|
||||
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer alpha-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,21 +82,31 @@ describe("models-config", () => {
|
||||
await withUnsetCopilotTokenEnv(async () => {
|
||||
const fetchMock = mockCopilotTokenExchangeSuccess();
|
||||
const agentDir = path.join(home, "agent-profiles");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
process.env.COPILOT_REF_TOKEN = "token-from-ref-env";
|
||||
try {
|
||||
await writeAuthProfiles(agentDir, {
|
||||
"github-copilot:default": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
tokenRef: { source: "env", provider: "default", id: "COPILOT_REF_TOKEN" },
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:default": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
tokenRef: { source: "env", provider: "default", id: "COPILOT_REF_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
expectBearerAuthHeader(fetchMock, "token-from-ref-env");
|
||||
} finally {
|
||||
delete process.env.COPILOT_REF_TOKEN;
|
||||
}
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer token-from-ref-env");
|
||||
delete process.env.COPILOT_REF_TOKEN;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
|
||||
|
||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||
session: createPerSenderSessionConfig(),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
@@ -22,7 +24,10 @@ describe("agents_list", () => {
|
||||
|
||||
function setConfigWithAgentList(agentList: AgentConfig[]) {
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig(),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
list: agentList,
|
||||
},
|
||||
@@ -46,7 +51,10 @@ describe("agents_list", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig(),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
@@ -14,7 +13,10 @@ vi.mock("../gateway/call.js", () => ({
|
||||
|
||||
let storeTemplatePath = "";
|
||||
let configOverride: Record<string, unknown> = {
|
||||
session: createPerSenderSessionConfig(),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
@@ -33,7 +35,11 @@ function writeStore(agentId: string, store: Record<string, unknown>) {
|
||||
|
||||
function setSubagentLimits(subagents: Record<string, unknown>) {
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents,
|
||||
@@ -69,7 +75,11 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
`openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`,
|
||||
);
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
};
|
||||
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
@@ -167,7 +177,11 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
|
||||
it("rejects when active children for requester session reached maxChildrenPerAgent", async () => {
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
@@ -200,7 +214,11 @@ describe("sessions_spawn depth + child limits", () => {
|
||||
|
||||
it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => {
|
||||
configOverride = {
|
||||
session: createPerSenderSessionConfig({ store: storeTemplatePath }),
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
store: storeTemplatePath,
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
|
||||
@@ -55,40 +55,6 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
return tool.execute(callId, { task: "do thing", agentId, sandbox });
|
||||
}
|
||||
|
||||
function setResearchUnsandboxedConfig(params?: { includeSandboxedDefault?: boolean }) {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
...(params?.includeSandboxedDefault
|
||||
? {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAllowedSpawn(params: {
|
||||
allowAgents: string[];
|
||||
agentId: string;
|
||||
@@ -190,7 +156,33 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
});
|
||||
|
||||
it("forbids sandboxed cross-agent spawns that would unsandbox the child", async () => {
|
||||
setResearchUnsandboxedConfig({ includeSandboxedDefault: true });
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeSpawn("call11", "research");
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
@@ -201,7 +193,28 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
});
|
||||
|
||||
it('forbids sandbox="require" when target runtime is unsandboxed', async () => {
|
||||
setResearchUnsandboxedConfig();
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
subagents: {
|
||||
allowAgents: ["research"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeSpawn("call12", "research", "require");
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
@@ -285,8 +298,19 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "my-research_agent01" }],
|
||||
},
|
||||
});
|
||||
mockAcceptedSpawn(1000);
|
||||
const result = await executeSpawn("call-valid", "my-research_agent01");
|
||||
callGatewayMock.mockImplementation(async () => ({
|
||||
runId: "run-1",
|
||||
status: "accepted",
|
||||
acceptedAt: 1000,
|
||||
}));
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-valid", {
|
||||
task: "do thing",
|
||||
agentId: "my-research_agent01",
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
expect(details.status).toBe("accepted");
|
||||
});
|
||||
@@ -301,8 +325,19 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
mockAcceptedSpawn(1000);
|
||||
const result = await executeSpawn("call-unconfigured", "research");
|
||||
callGatewayMock.mockImplementation(async () => ({
|
||||
runId: "run-1",
|
||||
status: "accepted",
|
||||
acceptedAt: 1000,
|
||||
}));
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-unconfigured", {
|
||||
task: "do thing",
|
||||
agentId: "research",
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
// Must pass: "research" is in allowAgents even though not in agents.list
|
||||
expect(details.status).toBe("accepted");
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveSandboxInputPathMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./sandbox-paths.js", () => ({
|
||||
resolveSandboxInputPath: resolveSandboxInputPathMock,
|
||||
}));
|
||||
|
||||
import { toRelativeWorkspacePath } from "./path-policy.js";
|
||||
|
||||
describe("toRelativeWorkspacePath (windows semantics)", () => {
|
||||
beforeEach(() => {
|
||||
resolveSandboxInputPathMock.mockReset();
|
||||
resolveSandboxInputPathMock.mockImplementation((filePath: string) => filePath);
|
||||
});
|
||||
|
||||
it("accepts windows paths with mixed separators and case", () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
try {
|
||||
const root = "C:\\Users\\User\\OpenClaw";
|
||||
const candidate = "c:/users/user/openclaw/memory/log.txt";
|
||||
expect(toRelativeWorkspacePath(root, candidate)).toBe("memory\\log.txt");
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects windows paths outside workspace root", () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
try {
|
||||
const root = "C:\\Users\\User\\OpenClaw";
|
||||
const candidate = "C:\\Users\\User\\Other\\log.txt";
|
||||
expect(() => toRelativeWorkspacePath(root, candidate)).toThrow("Path escapes workspace root");
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { normalizeWindowsPathForComparison } from "../infra/path-guards.js";
|
||||
import { resolveSandboxInputPath } from "./sandbox-paths.js";
|
||||
|
||||
type RelativePathOptions = {
|
||||
@@ -14,35 +13,10 @@ function toRelativePathUnderRoot(params: {
|
||||
candidate: string;
|
||||
options?: RelativePathOptions;
|
||||
}): string {
|
||||
const resolvedInput = resolveSandboxInputPath(
|
||||
params.candidate,
|
||||
params.options?.cwd ?? params.root,
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const rootResolved = path.win32.resolve(params.root);
|
||||
const resolvedCandidate = path.win32.resolve(resolvedInput);
|
||||
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
|
||||
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
|
||||
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
const rootResolved = path.resolve(params.root);
|
||||
const resolvedCandidate = path.resolve(resolvedInput);
|
||||
const resolvedCandidate = path.resolve(
|
||||
resolveSandboxInputPath(params.candidate, params.options?.cwd ?? params.root),
|
||||
);
|
||||
const relative = path.relative(rootResolved, resolvedCandidate);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitSdkTools } from "./pi-embedded-runner.js";
|
||||
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
|
||||
|
||||
function createStubTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: "",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({}) as AgentToolResult<unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("splitSdkTools", () => {
|
||||
const tools = [
|
||||
|
||||
@@ -369,7 +369,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
sandbox,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
|
||||
@@ -584,7 +584,7 @@ export async function runEmbeddedAttempt(
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
@@ -751,7 +751,7 @@ export async function runEmbeddedAttempt(
|
||||
sandbox: (() => {
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
});
|
||||
return { mode: runtime.mode, sandboxed: runtime.sandboxed };
|
||||
})(),
|
||||
@@ -1185,7 +1185,7 @@ export async function runEmbeddedAttempt(
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user