mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
43 Commits
vincentkoc
...
stack/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c551ac48 | ||
|
|
4ad8bb8630 | ||
|
|
e0fd16fb61 | ||
|
|
64ce1a11d4 | ||
|
|
2d676d6460 | ||
|
|
2e90bc3d7d | ||
|
|
cafb5c8e12 | ||
|
|
253aec92d1 | ||
|
|
78b7a72510 | ||
|
|
7f682a747d | ||
|
|
3fe4c19305 | ||
|
|
627813aba4 | ||
|
|
1ded5cc9a9 | ||
|
|
5f95f46070 | ||
|
|
5b8fc68ea2 | ||
|
|
9830b7c298 | ||
|
|
6d118ab815 | ||
|
|
4aa548cf7d | ||
|
|
4ffe15c6b2 | ||
|
|
2370ea5d1b | ||
|
|
ae29842158 | ||
|
|
b1b41eb443 | ||
|
|
5341b5c71c | ||
|
|
997197c6c9 | ||
|
|
de9031da22 | ||
|
|
75775f2fe6 | ||
|
|
dbccc73d7a | ||
|
|
fe92113472 | ||
|
|
1d7a287cf6 | ||
|
|
094140bdb1 | ||
|
|
b52c9f2575 | ||
|
|
de62ccbf81 | ||
|
|
9a5bfb1fe5 | ||
|
|
0b3bbfec06 | ||
|
|
b34530a05d | ||
|
|
e1503349c3 | ||
|
|
2a888c5703 | ||
|
|
786ff6afca | ||
|
|
2d67c9b2a0 | ||
|
|
a9ec75fe81 | ||
|
|
0566845b71 | ||
|
|
9083a3f2e3 | ||
|
|
85377a2817 |
155
.github/workflows/stale.yml
vendored
Normal file
155
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 10000
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
|
||||
lock-closed-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const lockAfterHours = 48;
|
||||
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
|
||||
const perPage = 100;
|
||||
const cutoffMs = Date.now() - lockAfterMs;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "resolved",
|
||||
});
|
||||
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -2,7 +2,31 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.3.2 (Unreleased)
|
||||
## 2026.3.3
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
|
||||
- Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
|
||||
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
|
||||
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
|
||||
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
|
||||
- Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
|
||||
- Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
|
||||
- Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
|
||||
- Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
|
||||
- Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
|
||||
- LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
|
||||
- LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
|
||||
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
|
||||
- LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
|
||||
- LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -36,9 +60,11 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
|
||||
- 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.
|
||||
- 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.
|
||||
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
|
||||
- Feishu/inbound mention normalization: preserve all inbound mention semantics by normalizing Feishu mention placeholders into explicit `<at user_id=\"...\">name</at>` tags (instead of stripping them), improving multi-mention context fidelity in agent prompts while retaining bot/self mention disambiguation. (#30252) Thanks @Lanfei.
|
||||
- Feishu/multi-app mention routing: guard mention detection in multi-bot groups by validating mention display name alongside bot `open_id`, preventing false-positive self-mentions from Feishu WebSocket remapping so only the actually mentioned bot responds under `requireMention`. (#30315) Thanks @teaguexiao.
|
||||
- Feishu/session-memory hook parity: trigger the shared `before_reset` session-memory hook path when Feishu `/new` and `/reset` commands execute so reset flows preserve memory behavior consistent with other channels. (#31437) Thanks @Linux2010.
|
||||
- Feishu/LINE group system prompts: forward per-group `systemPrompt` config into inbound context `GroupSystemPrompt` for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
|
||||
@@ -75,6 +101,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- Gateway/Webchat streaming finalization: flush throttled trailing assistant text before `final` chat events so streaming consumers do not miss tail content, while preserving duplicate suppression and heartbeat/silent lead-fragment guards. (#24856) Thanks @visionik and @vincentkoc.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- 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.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
@@ -91,7 +118,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @bmendonca3.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
|
||||
@@ -141,7 +168,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @AIflow-Labs.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -258,7 +285,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @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.
|
||||
@@ -284,12 +311,12 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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/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 @chilu18.
|
||||
- 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.
|
||||
- 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 @hou-rong.
|
||||
- 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 @PinoHouse.
|
||||
- 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.
|
||||
@@ -350,7 +377,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix/Directory room IDs: preserve original room-ID casing for direct `!roomId` group lookups (without `:server`) so allowlist checks do not fail on case-sensitive IDs. Landed from contributor PR #31201 by @williamos-dev. Thanks @williamos-dev.
|
||||
- Discord/Inbound media fallback: preserve attachment and sticker metadata when Discord CDN fetch/save fails by keeping URL-based media entries in context, with regression coverage for save failures and mixed success/failure ordering. Landed from contributor PR #28906 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Auto-reply/Block reply timeout path: normalize `onBlockReply(...)` execution through `Promise.resolve(...)` before timeout wrapping so mixed sync/async callbacks keep deterministic timeout behavior across strict TypeScript build paths. (#19779) Thanks @dalefrieswthat and @vincentkoc.
|
||||
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @Glucksberg.
|
||||
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @arosstale.
|
||||
- Docs/Docker images: clarify the official GHCR image source and tag guidance (`main`, `latest`, `<version>`), and document that `OPENCLAW_IMAGE` skips local image builds but still uses the repo-local compose/setup flow. (#27214, #31180) Fixes #15655. Thanks @ipl31.
|
||||
- Docs/Gateway Docker bind guidance: clarify bridge-network loopback behavior and require bind mode values (`auto`/`loopback`/`lan`/`tailnet`/`custom`) instead of host aliases in `gateway.bind`. (#28001) Thanks @Anandesh-Sharma and @vincentkoc.
|
||||
- Docker/Image base annotations: add OCI labels for base image plus source/documentation/license metadata, include revision/version/created labels in Docker release builds, and document annotation keys/release context in install docs. Fixes #27945. Thanks @vincentkoc.
|
||||
@@ -362,7 +389,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Ack reactions: add Discord-account-level `ackReactionScope` override and support explicit `off`/`none` values in shared config schemas to disable ack reactions per account. Landed from contributor PR #30400 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Discord/Forum thread tags: support `appliedTags` on Discord thread-create actions and map to `applied_tags` for forum/media starter posts, with targeted thread-creation regression coverage. Landed from contributor PR #30358 by @pushkarsingh32. Thanks @pushkarsingh32.
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #24270 by @pdd-cli. Thanks @pdd-cli.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
@@ -444,7 +471,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin.
|
||||
- Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen.
|
||||
- Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #26157 by @stakeswky. Thanks @stakeswky.
|
||||
- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1.
|
||||
- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase.
|
||||
- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
@@ -455,10 +482,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Transient request errors: classify Slack request-error messages like `Client network socket disconnected before secure TLS connection was established` as transient in unhandled-rejection fatal detection, preventing temporary network drops from crash-looping the gateway. (#23169) Thanks @graysurf.
|
||||
- Slack/Usage footer formatting: wrap session keys in inline code in full response-usage footers so Slack does not parse colon-delimited session segments as emoji shortcodes. (#30258) Thanks @pushkarsingh32.
|
||||
- Slack/Thread session isolation: route channel/group top-level messages into thread-scoped sessions (`:thread:<ts>`) and read inbound `previousTimestamp` from the resolved thread session key, preventing cross-thread context bleed and stale timestamp lookups. (#10686) Thanks @pablohrcarvalho.
|
||||
- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715) Thanks @Glucksberg.
|
||||
- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715) Thanks @AIflow-Labs.
|
||||
- Slack/Legacy streaming config: map boolean `channels.slack.streaming=false` to unified streaming mode `off` (with `nativeStreaming=false`) so legacy configs correctly disable draft preview/native streaming instead of defaulting to `partial`. (#25990) Thanks @chilu18.
|
||||
- Slack/Socket reconnect reliability: reconnect Socket Mode after disconnect/start failures using bounded exponential backoff with abort-aware waits, while preserving clean shutdown behavior and adding disconnect/error helper tests. (#27232) Thanks @pandego.
|
||||
- Memory/QMD update+embed output cap: discard captured stdout for `qmd update` and `qmd embed` runs (while keeping stderr diagnostics) so large index progress output no longer fails sync with `produced too much output` during boot/refresh. (#28900) Thanks @Glucksberg.
|
||||
- Memory/QMD update+embed output cap: discard captured stdout for `qmd update` and `qmd embed` runs (while keeping stderr diagnostics) so large index progress output no longer fails sync with `produced too much output` during boot/refresh. (#28900; landed from contributor PR #23311 by @haitao-sjsu) Thanks @haitao-sjsu.
|
||||
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
|
||||
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
|
||||
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
|
||||
@@ -700,7 +727,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
|
||||
- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
|
||||
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
|
||||
- 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.
|
||||
- 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 @ArsalanShakil.
|
||||
- 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.
|
||||
- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
|
||||
@@ -791,11 +818,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051.
|
||||
- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc.
|
||||
- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen.
|
||||
- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg.
|
||||
- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @miloudbelarebia.
|
||||
- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc.
|
||||
- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc.
|
||||
- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian.
|
||||
- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg.
|
||||
- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic.
|
||||
- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn.
|
||||
- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc.
|
||||
- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316.
|
||||
@@ -941,7 +968,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
|
||||
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) Thanks @steipete.
|
||||
- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
|
||||
- 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/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; landed from contributor PR #5005 by @Diaspar4u) Thanks @Diaspar4u.
|
||||
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) Thanks @steipete.
|
||||
- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
|
||||
- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
|
||||
@@ -1232,6 +1259,8 @@ Docs: https://docs.openclaw.ai
|
||||
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
|
||||
- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky.
|
||||
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
||||
- Mattermost: add opt-in native slash command support with registration lifecycle, callback route/token validation, multi-account token routing, and callback URL/path configuration (`channels.mattermost.commands.*`). (#16515) Thanks @echo931.
|
||||
- Mattermost: harden native slash callback auth-bypass behavior for configurable callback paths, add callback validation coverage, and clarify callback reachability/allowlist docs. (#32467) Thanks @mukhtharcm and @echo931.
|
||||
- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky.
|
||||
- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky.
|
||||
- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates.
|
||||
@@ -1631,7 +1660,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
|
||||
- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades.
|
||||
- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @briancolinger.
|
||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||
- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
|
||||
- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
|
||||
@@ -1954,7 +1983,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845)
|
||||
- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow.
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @gumadeiras.
|
||||
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
@@ -2104,7 +2133,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
|
||||
- Cron: reload store data when the store file is recreated or mtime changes.
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @sleontenko.
|
||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||
- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai.
|
||||
|
||||
@@ -2434,7 +2463,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.openclaw.ai/tools/web
|
||||
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @steipete.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.openclaw.ai/diagnostics/flags
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
@@ -2447,7 +2476,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @steipete.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||
|
||||
114
appcast.xml
114
appcast.xml
@@ -4,15 +4,14 @@
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.2</title>
|
||||
<pubDate>Tue, 03 Mar 2026 03:58:13 +0000</pubDate>
|
||||
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030201</sparkle:version>
|
||||
<sparkle:version>2026030290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs/Feishu webhook setup: clarify <code>verificationToken</code> configuration with Open Platform navigation steps, and align Feishu sender-allowlist guidance plus zh-CN channel documentation with current runtime behavior. (#31555)</li>
|
||||
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
|
||||
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
|
||||
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
|
||||
@@ -33,8 +32,6 @@
|
||||
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
|
||||
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
|
||||
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
|
||||
<li>Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (<code>MiniMax-M2.5</code>, <code>MiniMax-M2.5-highspeed</code>, <code>moonshot/kimi-k2.5</code>, <code>zai/glm-5</code>) and keep Moonshot model lists synced from shared source data.</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
@@ -48,6 +45,7 @@
|
||||
<li>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 (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
|
||||
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
|
||||
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
|
||||
<li>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.</li>
|
||||
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
|
||||
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
|
||||
@@ -163,6 +161,8 @@
|
||||
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
|
||||
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
|
||||
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
|
||||
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
|
||||
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
|
||||
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
|
||||
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
|
||||
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
|
||||
@@ -219,7 +219,7 @@
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2-beta.1/OpenClaw-2026.3.2.zip" length="23181552" type="application/octet-stream" sparkle:edSignature="Vi5KwJI+Min51xK4wtP6erxIynqEVp+x/8IOrNATpVJAcqyggBaxyINcevTs+86qLjtKx9afXgdYQfYa0bD4Aw=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.1</title>
|
||||
@@ -359,107 +359,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602260</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
|
||||
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
|
||||
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
|
||||
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
|
||||
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
|
||||
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
|
||||
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
|
||||
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.</li>
|
||||
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
|
||||
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
|
||||
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.</li>
|
||||
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
|
||||
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
|
||||
<li>Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.</li>
|
||||
<li>Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.</li>
|
||||
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
|
||||
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
|
||||
<li>Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)</li>
|
||||
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.</li>
|
||||
<li>Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.</li>
|
||||
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
|
||||
<li>TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)</li>
|
||||
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
|
||||
<li>Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)</li>
|
||||
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.</li>
|
||||
<li>Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
|
||||
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)</li>
|
||||
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.</li>
|
||||
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
|
||||
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
|
||||
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
|
||||
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
|
||||
<li>Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
|
||||
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)</li>
|
||||
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
|
||||
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
|
||||
<li>Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.</li>
|
||||
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
|
||||
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.</li>
|
||||
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.</li>
|
||||
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
|
||||
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
|
||||
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.</li>
|
||||
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
|
||||
<li>Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.</li>
|
||||
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
|
||||
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
|
||||
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
|
||||
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
|
||||
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
|
||||
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
|
||||
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
|
||||
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)</li>
|
||||
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
|
||||
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
|
||||
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
|
||||
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
|
||||
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
|
||||
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
|
||||
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
|
||||
<li>Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Config includes: harden <code>$include</code> file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.</li>
|
||||
<li>Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
|
||||
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)</li>
|
||||
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
|
||||
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
|
||||
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.</li>
|
||||
<li>Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)</li>
|
||||
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
|
||||
<li>Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
|
||||
<li>iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.</li>
|
||||
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# OpenClaw iOS App - Comprehensive Audit Report 2026
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~16,244 LOC), `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0 | Swift 6.0 (strict concurrency: complete)
|
||||
**Audit Team:** 5 specialized Opus 4.6 agents (Concurrency, API Modernization, Architecture, UI/UX, Security)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS app is a **well-engineered codebase** that has adopted many 2026 best practices: Swift 6 strict concurrency, the Observation framework (`@Observable`), `NavigationStack`, Keychain credential storage, and TLS certificate pinning. However, the audit identified **9 critical findings**, **17 high findings**, **29 medium findings**, and **25 low findings** across 5 audit domains.
|
||||
|
||||
### Overall Health Score: **B+** (74/100)
|
||||
|
||||
| Domain | Score | Grade | Key Issue |
|
||||
|--------|-------|-------|-----------|
|
||||
| Swift 6 Concurrency | 78/100 | B+ | 3 data race risks, 5 unsafe patterns |
|
||||
| iOS 26 API Modernization | 82/100 | A- | 1 deprecated framework, 4 dead code paths |
|
||||
| Architecture & Code Quality | 62/100 | C+ | 2 god objects, 11.6% test coverage ratio |
|
||||
| UI/UX & Accessibility | 65/100 | C+ | Zero Dynamic Type, zero localization |
|
||||
| Security & Performance | 85/100 | A | No critical vulns, 3 high storage issues |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings (9)
|
||||
|
||||
### Concurrency (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| CON-C1 | `GatewayTLSFingerprintProbe` data race: `objc_sync_enter` with unsynchronized `didFinish`/`session`/`task` reads in `start()` | `Gateway/GatewayConnectionController.swift:992-1058` | Crash/undefined behavior |
|
||||
| CON-C2 | `PhotoCaptureDelegate` & `MovieFileDelegate` unsynchronized `didResume` flag can double-resume `CheckedContinuation` | `Camera/CameraController.swift:260-339` | Fatal crash (debug), UB (release) |
|
||||
| CON-C3 | `GatewayDiagnostics.logWritesSinceCheck` uses `nonisolated(unsafe)` suppressing all compiler race checks | `Gateway/GatewaySettingsStore.swift:358` | Silent data race |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| ARC-C1 | `NodeAppModel` is a 2,787 LOC god object with ~17 responsibilities | `Model/NodeAppModel.swift` | Untestable, unmaintainable |
|
||||
| ARC-C2 | `TalkModeManager` is a 2,153 LOC god object centralizing speech, audio, PTT, and gateway comms | `Voice/TalkModeManager.swift` | Same as above |
|
||||
|
||||
### UI/UX (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| UIX-C1 | `RootCanvas` voiceWakeToast animations ignore `accessibilityReduceMotion` | `RootCanvas.swift:159-167` | Accessibility violation |
|
||||
| UIX-C2 | `TalkOrbOverlay` perpetual pulse animations ignore `accessibilityReduceMotion` | `Voice/TalkOrbOverlay.swift:15-26` | Vestibular disorder risk |
|
||||
| UIX-C3 | `CameraFlashOverlay` has no VoiceOver announcement and no reduced motion check | `RootCanvas.swift:405-429` | Accessibility violation, photosensitivity |
|
||||
|
||||
### API Modernization (1)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| API-C1 | `NetService` usage (deprecated since iOS 16, removed in future SDKs) while `NWBrowser` already used for discovery | `Gateway/GatewayServiceResolver.swift`, `Gateway/GatewayConnectionController.swift:560-657` | Future SDK breakage |
|
||||
|
||||
---
|
||||
|
||||
## High Findings (17)
|
||||
|
||||
### Concurrency (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| CON-H1 | `ScreenRecordService` `UncheckedSendableBox<T>` wraps any T as Sendable, silencing compiler | `Screen/ScreenRecordService.swift:4-11` |
|
||||
| CON-H2 | `WatchMessagingService` `@unchecked Sendable` with `WCSession` property reads unprotected | `Services/WatchMessagingService.swift:23-28` |
|
||||
| CON-H3 | `LocationService` stores `CheckedContinuation` as instance vars with `nonisolated` delegate callbacks hopping to `@MainActor` | `Location/LocationService.swift:13-14` |
|
||||
| CON-H4 | `LiveNotificationCenter` wraps non-Sendable `UNUserNotificationCenter` in `@unchecked Sendable` | `Services/NotificationService.swift:18-58` |
|
||||
| CON-H5 | `NetworkStatusService` is `@unchecked Sendable` but stateless - unnecessary annotation | `Device/NetworkStatusService.swift:5` |
|
||||
|
||||
### Security (3)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| SEC-H1 | TLS fingerprints stored in UserDefaults (backup-extractable trust anchor) | `OpenClawKit/GatewayTLSPinning.swift:19-38` |
|
||||
| SEC-H2 | `KeychainStore` update path doesn't enforce `kSecAttrAccessible` on existing items | `Gateway/KeychainStore.swift:20-37` |
|
||||
| SEC-H3 | Gateway connection metadata (host/port/topology) in UserDefaults | `Gateway/GatewaySettingsStore.swift:170-217` |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| ARC-H1 | 3 oversized files: `GatewayConnectionController` (1,058 LOC), `SettingsTab` (1,032 LOC), `OnboardingWizardView` (884 LOC) | Various |
|
||||
| ARC-H2 | 17 source modules with zero test coverage; 11.6% test LOC ratio | See gap analysis |
|
||||
|
||||
### UI/UX (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| UIX-H1 | Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize`) | All view files |
|
||||
| UIX-H2 | Zero localization infrastructure (all hardcoded English) | All source files |
|
||||
| UIX-H3 | Zero haptic feedback in entire app | All source files |
|
||||
| UIX-H4 | OnboardingWizardView missing accessibility labels on mode selection rows | `Onboarding/OnboardingWizardView.swift` |
|
||||
| UIX-H5 | `GatewayTrustPromptAlert` and `DeepLinkAgentPromptAlert` use deprecated `Alert` API | `Gateway/GatewayTrustPromptAlert.swift` |
|
||||
|
||||
### API Modernization (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| API-H1 | Dead `#available(iOS 15/18)` checks (deployment target is iOS 18.0) | `OpenClawApp.swift:344`, `Camera/CameraController.swift:222-249` |
|
||||
| API-H2 | `UNUserNotificationCenter` callback APIs wrapped in continuations instead of native async | `OpenClawApp.swift:429-462` |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Themes
|
||||
|
||||
### 1. God Object Pattern
|
||||
`NodeAppModel` (2,787 LOC) and `TalkModeManager` (2,153 LOC) together represent **30%** of the entire codebase. Both have `// swiftlint:disable` suppressions acknowledging the problem. This is the single highest-impact improvement opportunity.
|
||||
|
||||
### 2. Inconsistent Synchronization Primitives
|
||||
The codebase uses 4 different synchronization mechanisms: `NSLock` (6 usages), `OSAllocatedUnfairLock` (1 usage), `objc_sync_enter/exit` (1 usage), and `DispatchQueue` serialization (7 usages). Standardizing on `OSAllocatedUnfairLock` + actors would improve consistency and safety.
|
||||
|
||||
### 3. UserDefaults Overuse
|
||||
~70+ direct `UserDefaults.standard` reads/writes with raw string keys across the codebase. TLS fingerprints, gateway metadata, and connection details stored in UserDefaults should be in Keychain. Non-sensitive preferences lack a typed key registry.
|
||||
|
||||
### 4. Missing Accessibility Infrastructure
|
||||
Dynamic Type, localization, and haptic feedback are completely absent. Three views ignore `accessibilityReduceMotion`. This represents the largest gap relative to Apple's 2026 HIG expectations.
|
||||
|
||||
### 5. Test Coverage Gaps
|
||||
11.6% test LOC ratio with 17 untested modules. The gateway reconnect state machine (most complex logic), background lifecycle, onboarding flow, and TalkModeManager have minimal or zero test coverage.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis (Top 15 Gaps)
|
||||
|
||||
| Module | Source LOC | Test LOC | Coverage |
|
||||
|--------|-----------|----------|----------|
|
||||
| `NodeAppModel.swift` | 2,787 | 478 (invoke only) | Partial - reconnect/background/deep links untested |
|
||||
| `TalkModeManager.swift` | 2,153 | 31 (config only) | Minimal |
|
||||
| `GatewayConnectionController.swift` | 1,058 | 226 | Partial - no TLS/Bonjour/autoconnect tests |
|
||||
| `SettingsTab.swift` | 1,032 | 8 (smoke) | Smoke only |
|
||||
| `OnboardingWizardView.swift` | 884 | 0 | None |
|
||||
| `OpenClawApp.swift` | 541 | 0 | None |
|
||||
| `RootCanvas.swift` | 429 | 8 (smoke) | Smoke only |
|
||||
| `GatewayOnboardingView.swift` | 371 | 0 | None |
|
||||
| `WatchMessagingService.swift` | 284 | 0 | None |
|
||||
| `ContactsService.swift` | 210 | 0 | None |
|
||||
| `LocationService.swift` | 177 | 0 | None |
|
||||
| `PhotoLibraryService.swift` | 164 | 0 | None |
|
||||
| `CalendarService.swift` | 135 | 0 | None |
|
||||
| `RemindersService.swift` | 133 | 0 | None |
|
||||
| `MotionService.swift` | 100 | 0 | None |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 1 | Fix `PhotoCaptureDelegate`/`MovieFileDelegate` `didResume` synchronization (CON-C2) | Small | Prevents crashes |
|
||||
| 2 | Fix `GatewayTLSFingerprintProbe` data race (CON-C1) | Small | Prevents undefined behavior |
|
||||
| 3 | Add `accessibilityReduceMotion` checks to `RootCanvas` and `TalkOrbOverlay` (UIX-C1, C2, C3) | Small | Accessibility compliance |
|
||||
| 4 | Replace `nonisolated(unsafe)` in `GatewayDiagnostics` (CON-C3) | Small | Compiler safety |
|
||||
|
||||
### Phase 2: High-Priority Improvements (Next Sprint)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 5 | Move TLS fingerprints to Keychain (SEC-H1) | Medium | Security hardening |
|
||||
| 6 | Fix `KeychainStore` update accessibility enforcement (SEC-H2) | Small | Security correctness |
|
||||
| 7 | Migrate `NetService` to Network framework (API-C1) | Large | Future-proofing |
|
||||
| 8 | Remove dead `#available` checks (API-H1) | Small | Code cleanup |
|
||||
| 9 | Replace `UNUserNotificationCenter` callbacks with async APIs (API-H2) | Small | Modernization |
|
||||
| 10 | Add `@ScaledMetric` Dynamic Type support to key views (UIX-H1) | Medium | Accessibility |
|
||||
|
||||
### Phase 3: Architecture Refactoring (Planned)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 11 | Split `NodeAppModel` into 5-6 focused types (ARC-C1) | Large | Testability, maintainability |
|
||||
| 12 | Split `TalkModeManager` into 3-4 focused types (ARC-C2) | Large | Same |
|
||||
| 13 | Extract `SettingsTab` into section sub-views (ARC-H1) | Medium | Maintainability |
|
||||
| 14 | Create typed UserDefaults key registry | Medium | Type safety |
|
||||
| 15 | Add test coverage for gateway reconnect state machine | Large | Regression safety |
|
||||
| 16 | Add test coverage for background lifecycle management | Medium | Regression safety |
|
||||
|
||||
### Phase 4: Polish & Hardening (Opportunistic)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 17 | Add localization infrastructure with `String(localized:)` (UIX-H2) | Large | International users |
|
||||
| 18 | Add haptic feedback to key interactions (UIX-H3) | Small | UX polish |
|
||||
| 19 | Standardize on `OSAllocatedUnfairLock` across codebase | Small | Consistency |
|
||||
| 20 | Replace Combine `Timer.publish`/`onReceive` with async patterns | Small | Modernization |
|
||||
| 21 | Add keyboard shortcuts for iPad (UIX-M5) | Small | iPad UX |
|
||||
| 22 | Gate `ELEVENLABS_API_KEY` env var behind `#if DEBUG` (SEC-M3) | Small | Security |
|
||||
| 23 | Enforce minimum interval between deep link prompts (SEC-M5) | Small | Security |
|
||||
| 24 | Add HMAC verification to QR setup codes (SEC-M6) | Medium | Security |
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Observation framework adoption** - Zero `ObservableObject` usage; consistent `@Observable` + `@Environment` throughout
|
||||
2. **Protocol-based DI** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities with default implementations
|
||||
3. **Keychain for credentials** - Tokens, passwords, instance IDs stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
4. **TLS certificate pinning** - TOFU model with SHA-256 fingerprint verification and user confirmation
|
||||
5. **`CameraController` as actor** - Exemplary Swift concurrency pattern for hardware resource management
|
||||
6. **Dual WebSocket sessions** - Node/operator separation provides good privilege scoping
|
||||
7. **Non-persistent WKWebView** - Canvas prevents session data leakage
|
||||
8. **Swift 6 strict concurrency** - Enabled project-wide with `SWIFT_STRICT_CONCURRENCY: complete`
|
||||
9. **`@Sendable` service protocols** - All service protocols correctly require `Sendable` conformance
|
||||
10. **Deep link confirmation** - Agent deep links require explicit user approval with length limits
|
||||
|
||||
---
|
||||
|
||||
## OWASP Mobile Top 10 Summary
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| M1: Improper Credential Usage | PASS |
|
||||
| M2: Inadequate Supply Chain Security | PASS |
|
||||
| M3: Insecure Authentication/Authorization | PASS |
|
||||
| M4: Insufficient Input/Output Validation | PASS |
|
||||
| M5: Insecure Communication | PASS (note: HTTP allowed in web views) |
|
||||
| M6: Inadequate Privacy Controls | PASS (note: location sent over TLS) |
|
||||
| M7: Insufficient Binary Protections | N/A |
|
||||
| M8: Security Misconfiguration | PASS (notes: H-1, H-3) |
|
||||
| M9: Insecure Data Storage | PASS (notes: H-1, H-3, M-2) |
|
||||
| M10: Insufficient Cryptography | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Reports
|
||||
|
||||
Individual audit reports with full code snippets and line-by-line analysis:
|
||||
|
||||
- [`audit-concurrency.md`](./audit-concurrency.md) - Swift 6 strict concurrency (20 findings)
|
||||
- [`audit-api-modernization.md`](./audit-api-modernization.md) - iOS 26 API modernization (19 findings)
|
||||
- [`audit-architecture.md`](./audit-architecture.md) - Architecture & test coverage (16 findings)
|
||||
- [`audit-uiux.md`](./audit-uiux.md) - UI/UX & accessibility (24 findings)
|
||||
- [`audit-security.md`](./audit-security.md) - Security & performance (18 findings)
|
||||
|
||||
---
|
||||
|
||||
*Generated by OpenClaw iOS Audit Team (5x Opus 4.6 agents) on 2026-03-02*
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
actor CameraController {
|
||||
struct CameraDeviceInfo: Codable, Sendable {
|
||||
@@ -260,7 +261,7 @@ actor CameraController {
|
||||
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false
|
||||
private let resumed = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(_ continuation: CheckedContinuation<Data, Error>) {
|
||||
self.continuation = continuation
|
||||
@@ -271,8 +272,12 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
|
||||
if let error {
|
||||
self.continuation.resume(throwing: error)
|
||||
@@ -301,15 +306,19 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
self.continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||
private let continuation: CheckedContinuation<URL, Error>
|
||||
private var didResume = false
|
||||
private let resumed = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(_ continuation: CheckedContinuation<URL, Error>) {
|
||||
self.continuation = continuation
|
||||
@@ -321,8 +330,12 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel
|
||||
from connections: [AVCaptureConnection],
|
||||
error: Error?)
|
||||
{
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
|
||||
if let error {
|
||||
let ns = error as NSError
|
||||
|
||||
@@ -9,6 +9,7 @@ import Darwin
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import os
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Security
|
||||
@@ -990,12 +991,16 @@ extension GatewayConnectionController {
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private struct ProbeState {
|
||||
var didFinish = false
|
||||
var session: URLSession?
|
||||
var task: URLSessionWebSocketTask?
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
private var didFinish = false
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private let state = OSAllocatedUnfairLock(initialState: ProbeState())
|
||||
|
||||
init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
|
||||
self.url = url
|
||||
@@ -1008,9 +1013,11 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
||||
config.timeoutIntervalForRequest = self.timeoutSeconds
|
||||
config.timeoutIntervalForResource = self.timeoutSeconds
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
self.session = session
|
||||
let task = session.webSocketTask(with: self.url)
|
||||
self.task = task
|
||||
self.state.withLock { s in
|
||||
s.session = session
|
||||
s.task = task
|
||||
}
|
||||
task.resume()
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
|
||||
@@ -1036,12 +1043,18 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
||||
}
|
||||
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self)
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.session?.invalidateAndCancel()
|
||||
let (shouldComplete, taskToCancel, sessionToInvalidate) = self.state.withLock { s -> (Bool, URLSessionWebSocketTask?, URLSession?) in
|
||||
guard !s.didFinish else { return (false, nil, nil) }
|
||||
s.didFinish = true
|
||||
let task = s.task
|
||||
let session = s.session
|
||||
s.task = nil
|
||||
s.session = nil
|
||||
return (true, task, session)
|
||||
}
|
||||
guard shouldComplete else { return }
|
||||
taskToCancel?.cancel(with: .goingAway, reason: nil)
|
||||
sessionToInvalidate?.invalidateAndCancel()
|
||||
self.onComplete(fingerprint)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ enum GatewaySettingsStore {
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
||||
private static let lastGatewayConnectionAccount = "lastConnection"
|
||||
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey."
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
@@ -140,11 +141,20 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String {
|
||||
private enum LastGatewayKind: String, Codable {
|
||||
case manual
|
||||
case discovered
|
||||
}
|
||||
|
||||
/// JSON-serializable envelope stored as a single Keychain entry.
|
||||
private struct LastGatewayConnectionData: Codable {
|
||||
var kind: LastGatewayKind
|
||||
var stableID: String
|
||||
var useTLS: Bool
|
||||
var host: String?
|
||||
var port: Int?
|
||||
}
|
||||
|
||||
static func loadTalkProviderApiKey(provider: String) -> String? {
|
||||
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
|
||||
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
||||
@@ -168,47 +178,93 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
|
||||
self.saveLastGatewayConnectionData(payload)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .discovered, stableID: stableID, useTLS: useTLS)
|
||||
self.saveLastGatewayConnectionData(payload)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> LastGatewayConnection? {
|
||||
// Migrate legacy UserDefaults entries on first access.
|
||||
self.migrateLastGatewayFromUserDefaultsIfNeeded()
|
||||
|
||||
guard let json = KeychainStore.loadString(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount),
|
||||
let data = json.data(using: .utf8),
|
||||
let stored = try? JSONDecoder().decode(LastGatewayConnectionData.self, from: data)
|
||||
else { return nil }
|
||||
|
||||
let stableID = stored.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
|
||||
if stored.kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: stored.useTLS)
|
||||
}
|
||||
|
||||
let host = (stored.host ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let port = stored.port ?? 0
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: stored.useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
// Clean up any legacy UserDefaults entries.
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func saveLastGatewayConnectionData(_ payload: LastGatewayConnectionData) -> Bool {
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return false }
|
||||
return KeychainStore.saveString(
|
||||
json, service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
}
|
||||
|
||||
/// Migrate legacy UserDefaults gateway.last.* keys into a single Keychain entry.
|
||||
private static func migrateLastGatewayFromUserDefaultsIfNeeded() {
|
||||
let defaults = UserDefaults.standard
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
guard !stableID.isEmpty else { return }
|
||||
|
||||
// Already migrated if Keychain entry exists.
|
||||
if KeychainStore.loadString(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount) != nil
|
||||
{
|
||||
// Clean up legacy keys.
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
return
|
||||
}
|
||||
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
|
||||
|
||||
if kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: useTLS)
|
||||
}
|
||||
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let port = defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int
|
||||
|
||||
// Back-compat: older builds persisted manual-style host/port without a kind marker.
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: kind, stableID: stableID, useTLS: useTLS,
|
||||
host: kind == .manual ? host : nil,
|
||||
port: kind == .manual ? port : nil)
|
||||
guard self.saveLastGatewayConnectionData(payload) else { return }
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
private static func removeLastGatewayDefaults(_ defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
@@ -355,9 +411,15 @@ enum GatewayDiagnostics {
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static var fileURL: URL? {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("openclaw-gateway.log")
|
||||
}
|
||||
|
||||
@@ -404,32 +466,41 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyFileProtection(url: URL) {
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
static func bootstrap() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
self.appendToLog(url: url, data: data)
|
||||
self.applyFileProtection(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func log(_ message: String) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.logWritesSinceCheck += 1
|
||||
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites {
|
||||
self.logWritesSinceCheck = 0
|
||||
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
||||
count += 1
|
||||
if count >= self.logSizeCheckEveryWrites {
|
||||
count = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if shouldTruncate {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
}
|
||||
let entry = line + "\n"
|
||||
|
||||
@@ -18,6 +18,9 @@ enum KeychainStore {
|
||||
}
|
||||
|
||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||
// Delete-then-add ensures kSecAttrAccessible is always applied.
|
||||
// SecItemUpdate cannot change the accessibility level of an existing item,
|
||||
// so a stale item created with a weaker policy would retain it on update.
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
@@ -25,10 +28,7 @@ enum KeychainStore {
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
let update: [String: Any] = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if status == errSecSuccess { return true }
|
||||
if status != errSecItemNotFound { return false }
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
|
||||
@@ -2591,8 +2591,8 @@ extension NodeAppModel {
|
||||
"agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
|
||||
return
|
||||
}
|
||||
if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 {
|
||||
self.deepLinkLogger.debug("agent deep link prompt throttled")
|
||||
if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 5.0 {
|
||||
self.deepLinkLogger.debug("agent deep link prompt rate-limited (min 5 s interval)")
|
||||
return
|
||||
}
|
||||
self.lastAgentDeepLinkPromptAt = Date()
|
||||
|
||||
@@ -72,6 +72,9 @@ final class TalkModeManager: NSObject {
|
||||
private var mainSessionKey: String = "main"
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
/// Set when the ElevenLabs API rejects PCM format (e.g. 403 subscription_required).
|
||||
/// Once set, all subsequent requests in this session use MP3 instead of re-trying PCM.
|
||||
private var pcmFormatUnavailable: Bool = false
|
||||
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
|
||||
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
|
||||
|
||||
@@ -987,9 +990,12 @@ final class TalkModeManager: NSObject {
|
||||
self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)")
|
||||
}
|
||||
|
||||
let resolvedKey =
|
||||
(self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ??
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
let configuredKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil
|
||||
#if DEBUG
|
||||
let resolvedKey = configuredKey ?? ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
#else
|
||||
let resolvedKey = configuredKey
|
||||
#endif
|
||||
let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId
|
||||
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||
@@ -1004,7 +1010,8 @@ final class TalkModeManager: NSObject {
|
||||
let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100")
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(
|
||||
requestedOutputFormat ?? self.effectiveDefaultOutputFormat)
|
||||
if outputFormat == nil, let requestedOutputFormat {
|
||||
self.logger.warning(
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||
@@ -1051,8 +1058,9 @@ final class TalkModeManager: NSObject {
|
||||
self.lastPlaybackWasPCM = true
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
@@ -1388,7 +1396,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? {
|
||||
if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil {
|
||||
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
}
|
||||
return context.outputFormat
|
||||
}
|
||||
@@ -1477,7 +1485,8 @@ final class TalkModeManager: NSObject {
|
||||
let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100")
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(
|
||||
requestedOutputFormat ?? self.effectiveDefaultOutputFormat)
|
||||
if outputFormat == nil, let requestedOutputFormat {
|
||||
self.logger.warning(
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||
@@ -1528,6 +1537,11 @@ final class TalkModeManager: NSObject {
|
||||
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
|
||||
}
|
||||
|
||||
/// Returns `mp3_44100_128` when the API has already rejected PCM, otherwise `pcm_44100`.
|
||||
private var effectiveDefaultOutputFormat: String {
|
||||
self.pcmFormatUnavailable ? "mp3_44100_128" : "pcm_44100"
|
||||
}
|
||||
|
||||
private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
for chunk in chunks {
|
||||
@@ -1583,8 +1597,9 @@ final class TalkModeManager: NSObject {
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
request: self.makeIncrementalTTSRequest(
|
||||
@@ -1920,6 +1935,7 @@ extension TalkModeManager {
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
self.pcmFormatUnavailable = false
|
||||
do {
|
||||
let res = try await gateway.request(
|
||||
method: "talk.config",
|
||||
|
||||
@@ -71,18 +71,37 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
withUserDefaults([:]) {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
|
||||
withUserDefaults([
|
||||
"gateway.last.kind": "manual",
|
||||
"gateway.last.host": "",
|
||||
|
||||
@@ -27,6 +27,7 @@ private let lastGatewayDefaultsKeys = [
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -84,9 +85,13 @@ private func withBootstrapSnapshots(_ body: () -> Void) {
|
||||
body()
|
||||
}
|
||||
|
||||
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry])
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
@@ -147,28 +152,24 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
@Test func lastGateway_discoveredOverwritesManual() {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "10.0.0.99",
|
||||
port: 18789,
|
||||
useTLS: true,
|
||||
stableID: "manual|10.0.0.99|18789")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
@Test func lastGateway_migratesFromUserDefaults() {
|
||||
withLastGatewaySnapshot {
|
||||
// Clear Keychain entry and plant legacy UserDefaults values.
|
||||
applyKeychain([lastGatewayKeychainEntry: nil])
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
@@ -179,6 +180,11 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
|
||||
// Legacy keys should be cleaned up after migration.
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.stableID") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
478
apps/ios/audit-api-modernization.md
Normal file
478
apps/ios/audit-api-modernization.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# iOS API Modernization Audit Report
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** API Modernization Expert (Claude Opus 4.6)
|
||||
**Scope:** All Swift source files in `apps/ios/Sources/`, `apps/ios/WatchExtension/Sources/`, and `apps/ios/ShareExtension/`
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0
|
||||
**Swift Version:** 6.0 (strict concurrency: complete)
|
||||
**Xcode Version:** 16.0
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS codebase is well-maintained and has already adopted many modern Swift and iOS patterns. The Observation framework (`@Observable`, `@Bindable`, `@Environment(ModelType.self)`) is used consistently throughout. `NavigationStack` is used instead of the deprecated `NavigationView`. Swift 6 strict concurrency is enabled project-wide.
|
||||
|
||||
However, there are several areas where deprecated APIs remain in use, unnecessary availability checks exist (dead code given iOS 18.0 deployment target), and legacy callback-based APIs are wrapped in continuations where native async alternatives are available.
|
||||
|
||||
### Summary by Severity
|
||||
|
||||
| Severity | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Critical | 1 | Deprecated `NetService` usage (removed in future SDKs) |
|
||||
| High | 4 | Dead availability-check code, legacy callback wrapping |
|
||||
| Medium | 8 | Callback APIs with async alternatives, legacy patterns |
|
||||
| Low | 6 | Minor modernization opportunities, style improvements |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `NetService` Usage (Deprecated Since iOS 16)
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/GatewayServiceResolver.swift` (entire file)
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift` (lines ~560-657)
|
||||
|
||||
**Current Code:**
|
||||
`GatewayServiceResolver` is built entirely on `NetService` and `NetServiceDelegate`, which have been deprecated since iOS 16. `GatewayConnectionController` uses `NetService` for Bonjour resolution in `resolveBonjourServiceToHostPort`.
|
||||
|
||||
**Risk:** Apple may remove `NetService` entirely in a future SDK. The app already uses `NWBrowser` (Network framework) for discovery in `GatewayDiscoveryModel.swift`, creating an inconsistency where discovery uses the modern API but resolution falls back to the deprecated one.
|
||||
|
||||
**Recommended Replacement:** Migrate to `NWConnection` for TCP connection establishment and use the endpoint information from `NWBrowser` results directly, eliminating the need for a separate `NetService`-based resolver. The `NWBrowser.Result` already provides `NWEndpoint` values that can be used with `NWConnection` without resolution.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Unnecessary `#available(iOS 15.0, *)` Check
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, line 344
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 15.0, *) { ... }
|
||||
```
|
||||
|
||||
**Issue:** The deployment target is iOS 18.0, so this check is always true. The code inside the `#available` block executes unconditionally, and the compiler may warn about this.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and keep only the body.
|
||||
|
||||
### H-2: Dead `AVAssetExportSession` Fallback Code
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift`, lines ~222-249
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
|
||||
try await exportSession.export(to: fileURL, as: .mp4)
|
||||
} else {
|
||||
exportSession.outputURL = fileURL
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
// ...legacy error check...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `else` branch is dead code since the deployment target is iOS 18.0. The `#available` check is always true.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and the `else` branch entirely. Use only the modern `export(to:as:)` API.
|
||||
|
||||
### H-3: Callback-Based `UNUserNotificationCenter` APIs Wrapped in Continuations
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, lines ~429-462
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let settings = await withCheckedContinuation { cont in
|
||||
center.getNotificationSettings { settings in
|
||||
cont.resume(returning: settings)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` has had native async APIs since iOS 15:
|
||||
- `center.notificationSettings()` (replaces `getNotificationSettings`)
|
||||
- `center.notificationCategories()` (replaces `getNotificationCategories`)
|
||||
- `try await center.add(request)` (replaces `add(_:completionHandler:)`)
|
||||
|
||||
The Watch app (`WatchInboxStore.swift`, line 161) already correctly uses the modern async pattern: `await center.notificationSettings()`.
|
||||
|
||||
**Recommended Fix:** Replace all `withCheckedContinuation` wrappers around `UNUserNotificationCenter` with their native async equivalents.
|
||||
|
||||
### H-4: `NSItemProvider.loadItem` Callback Pattern in Share Extension
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, lines ~501-547
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
|
||||
// ...
|
||||
continuation.resume(returning: ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSItemProvider` has had modern async alternatives since iOS 16:
|
||||
- `try await provider.loadItem(forTypeIdentifier:)` for basic loading
|
||||
- `try await provider.loadDataRepresentation(for:)` with `UTType` parameter
|
||||
- `try await provider.loadFileRepresentation(for:)`
|
||||
|
||||
Three separate methods (`loadURLValue`, `loadTextValue`, `loadDataValue`) all wrap callbacks in continuations.
|
||||
|
||||
**Recommended Fix:** Adopt the modern `NSItemProvider` async APIs, using `UTType` parameters instead of string identifiers where possible.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `CLLocationManager` Delegate Pattern vs Modern `CLLocationUpdate` API
|
||||
|
||||
**File:** `apps/ios/Sources/Location/LocationService.swift` (entire file)
|
||||
|
||||
**Current Code:** Uses `CLLocationManagerDelegate` with:
|
||||
- `startUpdatingLocation()` / `stopUpdatingLocation()`
|
||||
- `startMonitoringSignificantLocationChanges()`
|
||||
- `requestWhenInUseAuthorization()` / `requestAlwaysAuthorization()`
|
||||
- `locationManager(_:didUpdateLocations:)` delegate callback
|
||||
|
||||
**Modern Alternative (iOS 17+):**
|
||||
- `CLLocationUpdate.liveUpdates()` async sequence for continuous location
|
||||
- `CLMonitor` for region monitoring and significant location changes
|
||||
- `CLLocationManager.requestWhenInUseAuthorization()` still required for authorization, but updates are consumed via async sequences
|
||||
|
||||
**Impact:** The delegate pattern works but requires more boilerplate and is harder to compose with async/await code.
|
||||
|
||||
**Recommended Fix:** Migrate `startLocationUpdates` to use `CLLocationUpdate.liveUpdates()` and consider `CLMonitor` for significant location changes. Keep the authorization request methods as-is (no async alternative for those).
|
||||
|
||||
### M-2: `CMMotionActivityManager` and `CMPedometer` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Motion/MotionService.swift`, lines 23-81
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
activityManager.queryActivityStarting(from: startDate, to: endDate, to: OperationQueue.main) { activities, error in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** CoreMotion APIs still use callbacks; there are no native async versions. However, wrapping in `withCheckedThrowingContinuation` is currently the correct approach.
|
||||
|
||||
**Recommended Fix:** No change needed at this time. Monitor for async CoreMotion APIs in future SDK releases.
|
||||
|
||||
### M-3: `EKEventStore.fetchReminders` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Reminders/RemindersService.swift`, lines 20-45
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { reminders in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** EventKit still uses callbacks for `fetchReminders`. The continuation wrapper is the correct approach for now.
|
||||
|
||||
**Recommended Fix:** No change needed. This is the standard pattern for callback-based EventKit APIs.
|
||||
|
||||
### M-4: `PHImageManager.requestImage` Synchronous Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`, line ~82
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
// ...
|
||||
imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: options) { image, _ in
|
||||
resultImage = image
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Uses `isSynchronous = true` which blocks the calling thread. Modern iOS apps should prefer async image loading. Consider using `PHImageManager`'s async image loading or the newer `PHPickerViewController` patterns for user-initiated selection.
|
||||
|
||||
**Recommended Fix:** If this code runs on a background thread (inside an actor), the synchronous pattern is acceptable for simplicity. Consider wrapping in a continuation if thread blocking becomes an issue.
|
||||
|
||||
### M-5: `NotificationCenter` Observer Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 105-113
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: UserDefaults.standard,
|
||||
queue: .main,
|
||||
using: { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handleUserDefaultsDidChange()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Modern Alternative (iOS 15+):**
|
||||
```swift
|
||||
// Use async notification sequence
|
||||
for await _ in NotificationCenter.default.notifications(named: UserDefaults.didChangeNotification) {
|
||||
self.handleUserDefaultsDidChange()
|
||||
}
|
||||
```
|
||||
|
||||
**Also in:** `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (uses `onReceive` with Combine publisher -- see M-8).
|
||||
|
||||
**Recommended Fix:** Replace callback-based observers with `NotificationCenter.default.notifications(named:)` async sequences in a `.task` modifier or dedicated Task.
|
||||
|
||||
### M-6: `DispatchQueue.asyncAfter` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/TCPProbe.swift`, line 39
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, line ~1016
|
||||
- `apps/ios/ShareExtension/ShareViewController.swift`, line 142
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
```
|
||||
|
||||
**Issue:** `DispatchQueue.asyncAfter` is a legacy GCD pattern. In Swift concurrency, `Task.sleep(nanoseconds:)` or `Task.sleep(for:)` is preferred. However, in `TCPProbe`, the GCD pattern is used within an `NWConnection` state handler context where a DispatchQueue is already in use, making it acceptable.
|
||||
|
||||
**Recommended Fix:**
|
||||
- `TCPProbe.swift`: Acceptable as-is (NWConnection requires a DispatchQueue).
|
||||
- `GatewayConnectionController.swift`: Replace with `Task.sleep` pattern.
|
||||
- `ShareViewController.swift`: Replace with `Task.sleep` + `MainActor.run`.
|
||||
|
||||
### M-7: `objc_sync_enter`/`objc_sync_exit` and `objc_setAssociatedObject`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, lines ~1039-1040, ~653
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
objc_sync_enter(connection)
|
||||
// ...
|
||||
objc_sync_exit(connection)
|
||||
```
|
||||
and
|
||||
```swift
|
||||
objc_setAssociatedObject(service, &resolvedKey, resolvedBox, .OBJC_ASSOCIATION_RETAIN)
|
||||
```
|
||||
|
||||
**Issue:** These are Objective-C runtime patterns. Swift has modern alternatives:
|
||||
- `OSAllocatedUnfairLock` (iOS 16+) or `Mutex` (proposed) for synchronization
|
||||
- Property wrappers or Swift-native patterns for associated state
|
||||
|
||||
Note: `TCPProbe.swift` correctly uses `OSAllocatedUnfairLock` already.
|
||||
|
||||
**Recommended Fix:** Replace `objc_sync_enter`/`objc_sync_exit` with `OSAllocatedUnfairLock`. For `objc_setAssociatedObject`, this will naturally be eliminated when migrating away from `NetService` (see C-1).
|
||||
|
||||
### M-8: Combine `Timer.publish` and `onReceive` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Onboarding/OnboardingWizardView.swift`, line ~72 (`Timer.publish`)
|
||||
- `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (`.onReceive(NotificationCenter.default.publisher(...))`)
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
@State private var autoAdvanceTimer = Timer.publish(every: 5.5, on: .main, in: .common).autoconnect()
|
||||
// ...
|
||||
.onReceive(self.autoAdvanceTimer) { _ in ... }
|
||||
```
|
||||
|
||||
**Issue:** `Timer.publish` is a Combine pattern. Modern SwiftUI alternatives include:
|
||||
- `.task { while !Task.isCancelled { ... try? await Task.sleep(...) } }` for recurring timers
|
||||
- `TimelineView(.periodic(from:, by:))` for UI-driven periodic updates
|
||||
|
||||
**Recommended Fix:** Replace `Timer.publish` with a `.task`-based loop using `Task.sleep`. Replace `onReceive(NotificationCenter.default.publisher(...))` with `.task` + `NotificationCenter.default.notifications(named:)` async sequence.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@unchecked Sendable` on `WatchConnectivityReceiver`
|
||||
|
||||
**File:** `apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift`, line 21
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { ... }
|
||||
```
|
||||
|
||||
**Issue:** `@unchecked Sendable` bypasses the compiler's sendability checks. The class holds a `WCSession?` and `WatchInboxStore` reference. Since `WatchInboxStore` is `@MainActor @Observable`, the receiver should ideally be restructured to use actor isolation or be marked `@MainActor`.
|
||||
|
||||
**Recommended Fix:** Consider making `WatchConnectivityReceiver` `@MainActor` or using an actor to protect shared state. The `WCSessionDelegate` methods dispatch to `@MainActor` already.
|
||||
|
||||
### L-2: `@unchecked Sendable` on `ScreenRecordService`
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift`
|
||||
|
||||
**Current Code:** Uses `@unchecked Sendable` with manual `NSLock`-based `CaptureState` synchronization.
|
||||
|
||||
**Issue:** Manual lock-based synchronization is error-prone. An actor would provide compiler-verified thread safety.
|
||||
|
||||
**Recommended Fix:** Consider converting `ScreenRecordService` to an actor, or at minimum replace `NSLock` with `OSAllocatedUnfairLock` for consistency with other parts of the codebase (e.g., `TCPProbe.swift`).
|
||||
|
||||
### L-3: `NSLock` Usage in `AudioBufferQueue`
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 15-38
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private final class AudioBufferQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSLock` is a valid synchronization primitive but `OSAllocatedUnfairLock` (iOS 16+) is more efficient and is already used elsewhere in the codebase.
|
||||
|
||||
**Recommended Fix:** Replace `NSLock` with `OSAllocatedUnfairLock` for consistency and performance. Note: this class is intentionally `@unchecked Sendable` because it runs on a realtime audio thread where actor isolation is not appropriate -- the manual lock pattern is correct here; just the lock type could be modernized.
|
||||
|
||||
### L-4: `DateFormatter` Usage Instead of `.formatted()`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift`, lines 49-67
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
```
|
||||
|
||||
**Issue:** Since iOS 15, Swift provides `Date.formatted()` with `FormatStyle` which is more type-safe and concise. The `WatchInboxView.swift` already uses the modern pattern: `updatedAt.formatted(date: .omitted, time: .shortened)`.
|
||||
|
||||
**Recommended Fix:** Replace `DateFormatter` with `Date.formatted(.dateTime.hour().minute().second())` for the time format and `Date.ISO8601FormatStyle` for ISO formatting.
|
||||
|
||||
### L-5: `UIScreen.main.bounds` Usage
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, line 31
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
|
||||
```
|
||||
|
||||
**Issue:** `UIScreen.main` is deprecated in iOS 16. In an extension context, `view.window?.windowScene?.screen` may not be available at `viewDidLoad` time, so the deprecation is harder to address here.
|
||||
|
||||
**Recommended Fix:** Since this is a share extension with limited lifecycle, this is acceptable. If refactoring, consider using trait collection or a fixed width, since the system manages extension sizing.
|
||||
|
||||
### L-6: String-Based `NSSortDescriptor` Key Path
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
NSSortDescriptor(key: "creationDate", ascending: false)
|
||||
```
|
||||
|
||||
**Issue:** String-based key paths are not type-safe. While Photos framework requires `NSSortDescriptor`, this is a known limitation of the framework.
|
||||
|
||||
**Recommended Fix:** No change needed. The Photos framework API requires `NSSortDescriptor` with string keys.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings (Already Modern)
|
||||
|
||||
The following modern patterns are already correctly adopted throughout the codebase:
|
||||
|
||||
| Pattern | Status | Files |
|
||||
|---------|--------|-------|
|
||||
| `@Observable` (Observation framework) | Adopted | `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `ScreenController`, `VoiceWakeManager`, `TalkModeManager`, `WatchInboxStore` |
|
||||
| `@Environment(ModelType.self)` | Adopted | All views consistently use this pattern |
|
||||
| `@Bindable` for two-way bindings | Adopted | `WatchInboxView`, various settings views |
|
||||
| `NavigationStack` (not `NavigationView`) | Adopted | All navigation uses `NavigationStack` |
|
||||
| Modern `onChange(of:) { _, newValue in }` | Adopted | All `onChange` modifiers use the two-parameter variant |
|
||||
| `NWBrowser` (Network framework) | Adopted | `GatewayDiscoveryModel` for Bonjour discovery |
|
||||
| `NWPathMonitor` (Network framework) | Adopted | `NetworkStatusService` |
|
||||
| `DataScannerViewController` (VisionKit) | Adopted | `QRScannerView` for QR code scanning |
|
||||
| `PhotosPicker` (PhotosUI) | Adopted | `OnboardingWizardView` |
|
||||
| `OSAllocatedUnfairLock` | Adopted | `TCPProbe` |
|
||||
| Swift 6 strict concurrency | Adopted | Project-wide `SWIFT_STRICT_CONCURRENCY: complete` |
|
||||
| `actor` isolation | Adopted | `CameraController` uses `actor` |
|
||||
| `@ObservationIgnored` | Adopted | `NodeAppModel` for non-tracked properties |
|
||||
| `OSLog` / `Logger` | Adopted | Throughout the codebase |
|
||||
| `async`/`await` | Adopted | Pervasive throughout the codebase |
|
||||
| No `ObservableObject` / `@StateObject` | Correct | No legacy `ObservableObject` usage found |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical (Immediate)
|
||||
1. **Migrate `NetService` to Network framework** (C-1) -- `GatewayServiceResolver` and `GatewayConnectionController` Bonjour resolution
|
||||
|
||||
### Phase 2: High (Next Sprint)
|
||||
2. **Remove dead `#available` checks** (H-1, H-2) -- `OpenClawApp.swift`, `CameraController.swift`
|
||||
3. **Replace `UNUserNotificationCenter` callback wrappers** (H-3) -- `OpenClawApp.swift`
|
||||
4. **Modernize `NSItemProvider` loading in Share Extension** (H-4) -- `ShareViewController.swift`
|
||||
|
||||
### Phase 3: Medium (Planned)
|
||||
5. **Migrate `CLLocationManager` delegate to `CLLocationUpdate`** (M-1) -- `LocationService.swift`
|
||||
6. **Replace `DispatchQueue.asyncAfter`** (M-6) -- `GatewayConnectionController.swift`, `ShareViewController.swift`
|
||||
7. **Replace `objc_sync` with `OSAllocatedUnfairLock`** (M-7) -- `GatewayConnectionController.swift`
|
||||
8. **Replace Combine `Timer.publish` and `onReceive`** (M-8) -- `OnboardingWizardView.swift`, `VoiceWakeWordsSettingsView.swift`
|
||||
9. **Replace callback-based `NotificationCenter` observers** (M-5) -- `VoiceWakeManager.swift`
|
||||
|
||||
### Phase 4: Low (Opportunistic)
|
||||
10. **Replace `NSLock` with `OSAllocatedUnfairLock`** (L-3) -- `VoiceWakeManager.swift`
|
||||
11. **Modernize `DateFormatter` to `FormatStyle`** (L-4) -- `GatewayDiscoveryDebugLogView.swift`
|
||||
12. **Address `@unchecked Sendable` patterns** (L-1, L-2) -- `WatchConnectivityReceiver`, `ScreenRecordService`
|
||||
|
||||
---
|
||||
|
||||
## Files Not Requiring Changes
|
||||
|
||||
The following files were audited and found to use modern patterns appropriately:
|
||||
|
||||
- `apps/ios/Sources/RootView.swift`
|
||||
- `apps/ios/Sources/RootTabs.swift`
|
||||
- `apps/ios/Sources/RootCanvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+Canvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift`
|
||||
- `apps/ios/Sources/Chat/ChatSheet.swift`
|
||||
- `apps/ios/Sources/Chat/IOSGatewayChatTransport.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceTab.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceWakePreferences.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySettingsStore.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayHealthMonitor.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectConfig.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionIssue.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySetupCode.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/KeychainStore.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenTab.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenWebView.swift`
|
||||
- `apps/ios/Sources/Onboarding/GatewayOnboardingView.swift`
|
||||
- `apps/ios/Sources/Onboarding/OnboardingStateStore.swift`
|
||||
- `apps/ios/Sources/Status/StatusPill.swift`
|
||||
- `apps/ios/Sources/Status/StatusGlassCard.swift`
|
||||
- `apps/ios/Sources/Status/StatusActivityBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayStatusBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayActionsDialog.swift`
|
||||
- `apps/ios/Sources/Status/VoiceWakeToast.swift`
|
||||
- `apps/ios/Sources/Device/DeviceInfoHelper.swift`
|
||||
- `apps/ios/Sources/Device/DeviceStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NetworkStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NodeDisplayName.swift`
|
||||
- `apps/ios/Sources/Services/NodeServiceProtocols.swift`
|
||||
- `apps/ios/Sources/Services/WatchMessagingService.swift`
|
||||
- `apps/ios/Sources/Services/NotificationService.swift`
|
||||
- `apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift`
|
||||
- `apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift`
|
||||
- `apps/ios/Sources/SessionKey.swift`
|
||||
- `apps/ios/Sources/Calendar/CalendarService.swift`
|
||||
- `apps/ios/Sources/Contacts/ContactsService.swift`
|
||||
- `apps/ios/Sources/EventKit/EventKitAuthorization.swift`
|
||||
- `apps/ios/Sources/Location/SignificantLocationMonitor.swift`
|
||||
- `apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxStore.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxView.swift`
|
||||
- `apps/ios/WatchApp/` (asset catalog only)
|
||||
324
apps/ios/audit-architecture.md
Normal file
324
apps/ios/audit-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# iOS App Architecture, Code Quality & Test Coverage Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, 16,244 LOC) and `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
OpenClawApp (@main)
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
NodeAppModel GatewayConnectionController
|
||||
(@Observable) (@Observable)
|
||||
[God Object] [Discovery + Connect]
|
||||
| |
|
||||
+-----------+-----------+ GatewayDiscoveryModel
|
||||
| | | | | GatewaySettingsStore
|
||||
| | | | | GatewayHealthMonitor
|
||||
| | | | |
|
||||
v v v v v
|
||||
Screen Voice Camera Services Gateway Sessions
|
||||
Ctrl Wake Ctrl (proto) (node + operator)
|
||||
Talk
|
||||
Mode
|
||||
|
||||
UI Layer (SwiftUI):
|
||||
RootCanvas -> ScreenWebView + StatusPill + Overlays
|
||||
RootTabs -> ScreenTab, VoiceTab, SettingsTab
|
||||
Onboarding -> OnboardingWizardView, QRScannerView
|
||||
Chat -> ChatSheet (wraps OpenClawChatUI package)
|
||||
|
||||
Service Layer (protocols in NodeServiceProtocols.swift):
|
||||
CameraServicing, ScreenRecordingServicing, LocationServicing,
|
||||
DeviceStatusServicing, PhotosServicing, ContactsServicing,
|
||||
CalendarServicing, RemindersServicing, MotionServicing,
|
||||
WatchMessagingServicing
|
||||
|
||||
Routing: NodeCapabilityRouter (command -> handler dictionary)
|
||||
|
||||
Shared Packages: OpenClawKit, OpenClawChatUI, OpenClawProtocol, SwabbleKit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Findings by Severity
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1. NodeAppModel is a God Object (2,787 LOC)
|
||||
- **File:** `Sources/Model/NodeAppModel.swift`
|
||||
- **Lines:** 1-2787 (entire file)
|
||||
- **Description:** NodeAppModel concentrates ~17 distinct responsibilities in a single 2,787-line class:
|
||||
1. Gateway WebSocket lifecycle (two sessions: node + operator)
|
||||
2. Gateway reconnect state machine with exponential backoff
|
||||
3. Background task management (grace periods, leases, suppression)
|
||||
4. Deep link handling and agent prompt routing
|
||||
5. Voice wake coordination (suspend/resume around other audio)
|
||||
6. Talk mode coordination
|
||||
7. Camera HUD state management
|
||||
8. Screen recording state
|
||||
9. Canvas/A2UI invoke handling (present, hide, navigate, evalJS, snapshot, push, reset)
|
||||
10. Camera invoke handling (list, snap, clip)
|
||||
11. Location invoke handling
|
||||
12. Device/Photos/Contacts/Calendar/Reminders/Motion invoke handling
|
||||
13. Watch messaging and notification mirroring
|
||||
14. Push notification (APNs) token management
|
||||
15. Share extension relay configuration
|
||||
16. Branding/config refresh from gateway
|
||||
17. Session key management and agent selection
|
||||
- **Impact:** Extremely difficult to test in isolation, reason about, or modify safely. The file already has `// swiftlint:disable type_body_length file_length` which indicates a known but unaddressed problem.
|
||||
- **Recommendation:** Extract at least these into separate types:
|
||||
- `GatewayConnectionLoop` (reconnect state machine, background lease management)
|
||||
- `NodeInvokeDispatcher` (all `handleXxxInvoke` methods, currently ~600 LOC)
|
||||
- `VoiceAudioCoordinator` (voice wake + talk mode suspend/resume logic)
|
||||
- `BackgroundLifecycleManager` (grace periods, suppression, leases)
|
||||
- `DeepLinkHandler` (agent prompt, deep link parsing/routing)
|
||||
- `PushNotificationManager` (APNs token, notification authorization)
|
||||
|
||||
#### C2. TalkModeManager is a God Object (2,153 LOC)
|
||||
- **File:** `Sources/Voice/TalkModeManager.swift`
|
||||
- **Lines:** 1-2153 (entire file, saved to disk due to size)
|
||||
- **Description:** TalkModeManager contains speech recognition, audio playback, gateway communication, provider API key management, push-to-talk state machine, and TTS. The file header acknowledges this: "This file intentionally centralizes talk mode state + behavior. It's large, and splitting would force `private` -> `fileprivate` across many members."
|
||||
- **Impact:** The `private` -> `fileprivate` concern is valid but solvable with extensions in the same file or a dedicated module with `internal` access.
|
||||
- **Recommendation:** Extract `TalkAudioPlayer`, `TalkSpeechRecognitionEngine`, `TalkConfigLoader`, `TalkPTTStateMachine` into separate files.
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1. GatewayConnectionController is oversized (1,058 LOC)
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift`
|
||||
- **Lines:** 1-1058
|
||||
- **Description:** Exceeds the 500 LOC guideline. Mixes discovery coordination, TLS fingerprint verification, Bonjour service resolution, loopback IP detection, URL building, capability/command/permission registration, and auto-connect logic.
|
||||
- **Recommendation:** Extract `GatewayTLSVerifier`, `LoopbackHostDetector` (static utility), and `GatewayCapabilityRegistrar` (caps/commands/permissions).
|
||||
|
||||
#### H2. SettingsTab is oversized (1,032 LOC)
|
||||
- **File:** `Sources/Settings/SettingsTab.swift`
|
||||
- **Lines:** 1-1032
|
||||
- **Description:** A single monolithic SwiftUI view with ~30 `@AppStorage` properties and multiple nested sections.
|
||||
- **Recommendation:** Extract section views: `GatewaySettingsSection`, `VoiceSettingsSection`, `DeviceSettingsSection`, `AdvancedSettingsSection`.
|
||||
|
||||
#### H3. OnboardingWizardView is oversized (884 LOC)
|
||||
- **File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
- **Lines:** 1-884
|
||||
- **Description:** Multi-step wizard with QR scanning, manual connection, photo picker, and pairing logic all in one view.
|
||||
- **Recommendation:** Extract per-step views: `OnboardingWelcomeStep`, `OnboardingConnectStep`, `OnboardingAuthStep`.
|
||||
|
||||
#### H4. Heavy UserDefaults coupling (no abstraction layer)
|
||||
- **Files:** `NodeAppModel.swift`, `GatewayConnectionController.swift`, `SettingsTab.swift`, `GatewaySettingsStore.swift`, `RootCanvas.swift`
|
||||
- **Description:** `UserDefaults.standard` is accessed directly throughout the codebase (~70+ direct reads/writes with raw string keys). There is no typed key registry or wrapper, so:
|
||||
- Key typos compile silently
|
||||
- Default values are duplicated (e.g., `"camera.enabled"` checked with fallback `true` in two places)
|
||||
- Testing requires the `withUserDefaults` helper which mutates the shared `UserDefaults.standard`
|
||||
- **Recommendation:** Create a `Settings` enum with typed keys (similar to `VoiceWakePreferences`) and use dependency injection for `UserDefaults`.
|
||||
|
||||
#### H5. Significant test coverage gaps for critical paths
|
||||
- **Description:** Several critical modules have zero test coverage. See the Test Coverage Gap Analysis table below.
|
||||
- **Impact:** Changes to gateway connection lifecycle, background task management, voice/talk coordination, and canvas interaction cannot be regression-tested.
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1. Inconsistent module boundary patterns
|
||||
- **Description:** Some modules use proper protocol-based DI (camera, screen recording, location, device status, photos, contacts, calendar, reminders, motion, watch messaging via `NodeServiceProtocols.swift`), while others use concrete types directly:
|
||||
- `VoiceWakeManager` and `TalkModeManager` are concrete, not protocol-backed
|
||||
- `GatewayHealthMonitor` is concrete (but has testable init with sleep injection)
|
||||
- `ScreenController` is concrete with no protocol
|
||||
- `NotificationCentering` protocol exists but is ad hoc (not in `NodeServiceProtocols.swift`)
|
||||
- **Recommendation:** Add protocols for `VoiceWakeServicing`, `TalkModeServicing`, `ScreenControlling` to enable test doubles.
|
||||
|
||||
#### M2. Closure-based wiring instead of protocol conformance
|
||||
- **Files:** `NodeAppModel.swift:178-216`, `ScreenController.swift:14-18`
|
||||
- **Description:** `ScreenController.onDeepLink` and `ScreenController.onA2UIAction` are closure properties rather than delegate protocols. Similarly, `VoiceWakeManager.configure(onCommand:)` uses a closure. This makes the dependency graph harder to trace.
|
||||
- **Recommendation:** Consider delegate protocols for clearer contracts, or at minimum document the callback contracts.
|
||||
|
||||
#### M3. OpenClawApp.swift mixes concerns (541 LOC)
|
||||
- **File:** `Sources/OpenClawApp.swift`
|
||||
- **Lines:** 1-541
|
||||
- **Description:** Contains three distinct concerns in one file:
|
||||
1. `OpenClawAppDelegate` (push notifications, background tasks)
|
||||
2. `WatchPromptNotificationBridge` (notification category management, 200+ LOC)
|
||||
3. `OpenClawApp` (SwiftUI app entry point)
|
||||
- **Recommendation:** Extract `WatchPromptNotificationBridge` to its own file.
|
||||
|
||||
#### M4. GatewayDiagnostics embedded in GatewaySettingsStore file
|
||||
- **File:** `Sources/Gateway/GatewaySettingsStore.swift:352-448`
|
||||
- **Description:** `GatewayDiagnostics` enum (file-based logging) is defined at the bottom of `GatewaySettingsStore.swift` with no relation to settings storage.
|
||||
- **Recommendation:** Move to its own file `Gateway/GatewayDiagnostics.swift`.
|
||||
|
||||
#### M5. Duplicate code patterns in invoke handlers
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:1213-1358`
|
||||
- **Description:** Every `handleXxxInvoke` method follows the same pattern: decode params -> call service -> encode payload -> return response. The 12 invoke handlers repeat this boilerplate with minor variations. The `default:` case error response is duplicated 9 times verbatim.
|
||||
- **Recommendation:** Create a generic `invokeServiceMethod<P: Decodable, R: Encodable>` helper that handles the decode-call-encode-response cycle.
|
||||
|
||||
#### M6. No formal error domain or error catalog
|
||||
- **Description:** Errors are constructed ad hoc using `NSError(domain:code:userInfo:)` with inconsistent domains ("Screen", "Gateway", "Camera", "NodeAppModel", "GatewayHealthMonitor", "VoiceWake") and magic number codes. Only `CameraController.CameraError` uses a proper Swift error enum.
|
||||
- **Recommendation:** Define a unified `OpenClawIOSError` enum with cases for each domain, or at minimum use consistent error domains and documented code ranges.
|
||||
|
||||
#### M7. Two gateway sessions managed in parallel without shared state machine
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:96-98`
|
||||
- **Description:** `nodeGateway` and `operatorGateway` are two independent `GatewayNodeSession` instances with separate reconnect loops. Their connected states (`gatewayConnected`, `operatorConnected`) are tracked independently, but the UI only shows one "gateway status". Disconnect/reconnect of one does not coordinate with the other.
|
||||
- **Recommendation:** Extract a `DualGatewaySessionManager` that manages both sessions' lifecycles as a coordinated unit.
|
||||
|
||||
---
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1. `RootView.swift` is a trivial wrapper (7 LOC)
|
||||
- **File:** `Sources/RootView.swift`
|
||||
- **Description:** Contains only `struct RootView: View { var body: some View { RootCanvas() } }`. This adds an unnecessary layer of indirection.
|
||||
- **Recommendation:** Remove and use `RootCanvas` directly, or document why the indirection exists.
|
||||
|
||||
#### L2. Access control could be tighter
|
||||
- **Description:** Many types use default `internal` access where `private` or `fileprivate` would be more appropriate. For example:
|
||||
- `NodeAppModel.gatewayStatusText`, `nodeStatusText`, `operatorStatusText` are `var` (settable) from outside
|
||||
- `GatewayDiscoveryModel.gateways` is `var` (not `private(set)`)
|
||||
- `VoiceWakeManager.isEnabled`, `isListening` are publicly settable
|
||||
- **Recommendation:** Prefer `private(set)` for observable properties that should only be modified internally.
|
||||
|
||||
#### L3. `#if DEBUG` test hooks pattern
|
||||
- **Files:** `GatewayConnectionController.swift:929-989`, `VoiceWakeManager.swift:477-483`, `NodeAppModel.swift` (via `_test_` prefixed methods)
|
||||
- **Description:** Test hooks are exposed via `#if DEBUG` extensions with `_test_` prefixes. While functional, this pollutes the type's API surface.
|
||||
- **Recommendation:** This is a reasonable pattern for host-app tests. Consider using `@_spi(Testing)` when available in Swift 6 for cleaner separation.
|
||||
|
||||
#### L4. Naming inconsistency: `ThrowingContinuationSupport`
|
||||
- **File:** `Sources/OpenClawApp.swift:459`
|
||||
- **Description:** References `ThrowingContinuationSupport.resumeVoid` which appears to be defined in OpenClawKit. The name is verbose; a simple extension on `CheckedContinuation` would be more idiomatic.
|
||||
|
||||
#### L5. `GatewayTLSFingerprintProbe` uses `objc_sync_enter/exit` instead of a lock
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
- **Description:** `objc_sync_enter(self)` / `objc_sync_exit(self)` is an Objective-C runtime synchronization primitive. Modern Swift code should use `NSLock`, `os_unfair_lock`, or `Mutex` (Swift 6).
|
||||
- **Recommendation:** Replace with `NSLock` or `Mutex` for consistency with other lock usage (e.g., `NotificationInvokeLatch` uses `NSLock`).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis
|
||||
|
||||
| Source File | LOC | Test File | Test LOC | Coverage |
|
||||
|---|---|---|---|---|
|
||||
| `Model/NodeAppModel.swift` | 2787 | `NodeAppModelInvokeTests.swift` | 478 | **Partial** - invoke dispatch only; no tests for reconnect, background, deep links |
|
||||
| `Voice/TalkModeManager.swift` | 2153 | `TalkModeConfigParsingTests.swift` | 31 | **Minimal** - config parsing only; no PTT, speech, or playback tests |
|
||||
| `Gateway/GatewayConnectionController.swift` | 1058 | `GatewayConnectionControllerTests.swift` + `GatewayConnectionSecurityTests.swift` | 226 | **Partial** - security + basic flow; no TLS probe, Bonjour resolve, or autoconnect tests |
|
||||
| `Settings/SettingsTab.swift` | 1032 | `SwiftUIRenderSmokeTests.swift` (1 test) | ~8 | **Smoke only** - verifies view hierarchy builds |
|
||||
| `Onboarding/OnboardingWizardView.swift` | 884 | None | 0 | **None** |
|
||||
| `OpenClawApp.swift` | 541 | None | 0 | **None** - WatchPromptNotificationBridge untested |
|
||||
| `Voice/VoiceWakeManager.swift` | 483 | `VoiceWakeManagerStateTests.swift` + `VoiceWakeManagerExtractCommandTests.swift` | 144 | **Good** - state transitions + command extraction |
|
||||
| `Gateway/GatewaySettingsStore.swift` | 448 | `GatewaySettingsStoreTests.swift` | 197 | **Good** |
|
||||
| `RootCanvas.swift` | 429 | `SwiftUIRenderSmokeTests.swift` | ~8 | **Smoke only** |
|
||||
| `Onboarding/GatewayOnboardingView.swift` | 371 | None | 0 | **None** |
|
||||
| `Screen/ScreenRecordService.swift` | 350 | `ScreenRecordServiceTests.swift` | 32 | **Minimal** |
|
||||
| `Camera/CameraController.swift` | 339 | `CameraControllerClampTests.swift` + `CameraControllerErrorTests.swift` | 38 | **Minimal** - clamp/error only; no capture flow tests |
|
||||
| `Services/WatchMessagingService.swift` | 284 | None (mock in NodeAppModelInvokeTests) | 0 | **None** |
|
||||
| `Screen/ScreenController.swift` | 267 | `ScreenControllerTests.swift` | 87 | **Good** |
|
||||
| `Contacts/ContactsService.swift` | 210 | None | 0 | **None** |
|
||||
| `Screen/ScreenWebView.swift` | 193 | None | 0 | **None** |
|
||||
| `Gateway/GatewayDiscoveryModel.swift` | 181 | `GatewayDiscoveryModelTests.swift` | 22 | **Minimal** |
|
||||
| `Location/LocationService.swift` | 177 | None | 0 | **None** |
|
||||
| `Media/PhotoLibraryService.swift` | 164 | None | 0 | **None** |
|
||||
| `Chat/IOSGatewayChatTransport.swift` | 142 | `IOSGatewayChatTransportTests.swift` | 30 | **Minimal** |
|
||||
| `Calendar/CalendarService.swift` | 135 | None | 0 | **None** |
|
||||
| `Reminders/RemindersService.swift` | 133 | None | 0 | **None** |
|
||||
| `Motion/MotionService.swift` | 100 | None | 0 | **None** |
|
||||
| `Model/NodeAppModel+WatchNotifyNormalization.swift` | 103 | `VoiceWakeGatewaySyncTests.swift` (partial) | 22 | **Minimal** |
|
||||
| `Model/NodeAppModel+Canvas.swift` | 59 | None | 0 | **None** |
|
||||
| `Gateway/GatewayHealthMonitor.swift` | 85 | None | 0 | **None** |
|
||||
| `Gateway/KeychainStore.swift` | 48 | `KeychainStoreTests.swift` | 22 | **Minimal** |
|
||||
| `Onboarding/OnboardingStateStore.swift` | 52 | `OnboardingStateStoreTests.swift` | 57 | **Good** |
|
||||
| `Gateway/GatewayConnectionIssue.swift` | 71 | `GatewayConnectionIssueTests.swift` | 33 | **Good** |
|
||||
| `SessionKey.swift` | 23 | Tested via `NodeAppModelInvokeTests` | - | **Good** (indirectly) |
|
||||
| `Settings/SettingsNetworkingHelpers.swift` | 40 | `SettingsNetworkingHelpersTests.swift` | 50 | **Good** |
|
||||
| `Voice/VoiceWakePreferences.swift` | 44 | `VoiceWakePreferencesTests.swift` | 38 | **Good** |
|
||||
| `Device/NodeDisplayName.swift` | 48 | Tested via GatewayConnectionControllerTests | - | **Partial** |
|
||||
|
||||
### Coverage Summary
|
||||
- **63 source files**, **25 test files** (24 test + 1 helper)
|
||||
- **17 source modules with zero test coverage** (service implementations, onboarding views, several gateway files)
|
||||
- **Test LOC ratio:** 1,884 / 16,244 = **11.6%** (low for a production app)
|
||||
- **Test framework:** Swift Testing (`@Test`, `#expect`) -- modern and correct
|
||||
- **Test patterns:** Good use of mocks (MockWatchMessagingService), `withUserDefaults` helper for isolation, `_test_` hooks for internal access. SwiftUI render smoke tests validate view hierarchy construction.
|
||||
|
||||
### Critical Untested Paths
|
||||
1. **Gateway reconnect state machine** - the most complex logic in the app (background lease, pairing pause, backoff) has zero tests
|
||||
2. **Background lifecycle management** - grace periods, suppression, wake handling untested
|
||||
3. **Onboarding flow** - 1,255 LOC across 3 files with zero tests
|
||||
4. **Push notification handling** - APNs registration, silent push, background refresh untested
|
||||
5. **TalkModeManager** - 2,153 LOC with only 31 LOC of config parsing tests
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Assessment
|
||||
|
||||
### Well-Injected (protocol-based, testable)
|
||||
All services in `NodeServiceProtocols.swift` are protocol-based with default production implementations:
|
||||
- `CameraServicing` -> `CameraController`
|
||||
- `ScreenRecordingServicing` -> `ScreenRecordService`
|
||||
- `LocationServicing` -> `LocationService`
|
||||
- `DeviceStatusServicing` -> `DeviceStatusService`
|
||||
- `PhotosServicing` -> `PhotoLibraryService`
|
||||
- `ContactsServicing` -> `ContactsService`
|
||||
- `CalendarServicing` -> `CalendarService`
|
||||
- `RemindersServicing` -> `RemindersService`
|
||||
- `MotionServicing` -> `MotionService`
|
||||
- `WatchMessagingServicing` -> `WatchMessagingService`
|
||||
- `NotificationCentering` -> `LiveNotificationCenter`
|
||||
|
||||
`NodeAppModel.init()` accepts all of these via parameters with defaults -- excellent DI pattern.
|
||||
|
||||
### Not Injected (hardcoded dependencies)
|
||||
- `GatewaySettingsStore` - static enum, not injectable. Tests must use real `UserDefaults`/Keychain.
|
||||
- `GatewayDiagnostics` - static enum with file I/O, not injectable.
|
||||
- `GatewayDiscoveryModel` - concrete class created inside `GatewayConnectionController.init`.
|
||||
- `GatewayHealthMonitor` - created internally by `NodeAppModel` (but has testable init).
|
||||
- `VoiceWakeManager` - created internally, injected into SwiftUI environment.
|
||||
- `TalkModeManager` - injected via `NodeAppModel.init` parameter (good).
|
||||
- `ScreenController` - injected via `NodeAppModel.init` parameter (good).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### Observation Framework Usage
|
||||
The app uses Swift's `Observation` framework (`@Observable`) consistently:
|
||||
- `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `VoiceWakeManager`, `TalkModeManager`, `ScreenController` are all `@Observable`.
|
||||
- SwiftUI views access them via `@Environment(Type.self)`.
|
||||
- No legacy `ObservableObject` / `@StateObject` patterns found -- this is correct per CLAUDE.md guidance.
|
||||
|
||||
### Environment Propagation
|
||||
```
|
||||
OpenClawApp
|
||||
|-- @State NodeAppModel -> .environment(appModel)
|
||||
|-- @State GatewayConnectionController -> .environment(gatewayController)
|
||||
|-- appModel.voiceWake (VoiceWakeManager) -> .environment(appModel.voiceWake)
|
||||
```
|
||||
This is clean, though `voiceWake` being both a property of `NodeAppModel` AND injected separately into the environment creates a potential consistency issue if they ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Strengths
|
||||
|
||||
1. **Strong protocol-based DI for services** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities, enabling easy mocking in tests.
|
||||
2. **Modern Swift 6 / Observation adoption** - No legacy `ObservableObject` patterns; strict concurrency enabled.
|
||||
3. **NodeCapabilityRouter** - Clean command-routing pattern that decouples command registration from handling.
|
||||
4. **Dual gateway session architecture** - Separating node (device capabilities) from operator (chat/config) connections is architecturally sound.
|
||||
5. **GatewayConnectConfig** - Single source of truth struct for connection parameters.
|
||||
6. **Consistent input validation** - Nearly every string input is trimmed and empty-checked.
|
||||
7. **Keychain-based credential storage** - Sensitive data (tokens, passwords) stored in Keychain, not UserDefaults.
|
||||
8. **`CameraController` uses actor isolation** - Correct concurrency pattern for hardware resource.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Refactoring Priority
|
||||
|
||||
1. **[CRITICAL]** Split `NodeAppModel` into 5-6 focused types (highest ROI for testability)
|
||||
2. **[CRITICAL]** Split `TalkModeManager` into 3-4 focused types
|
||||
3. **[HIGH]** Add tests for gateway reconnect state machine
|
||||
4. **[HIGH]** Add tests for background lifecycle management
|
||||
5. **[HIGH]** Extract `SettingsTab` into section views
|
||||
6. **[MEDIUM]** Create typed `UserDefaults` key registry
|
||||
7. **[MEDIUM]** Unify error handling with a proper error catalog
|
||||
8. **[MEDIUM]** Extract duplicate invoke handler boilerplate
|
||||
399
apps/ios/audit-concurrency.md
Normal file
399
apps/ios/audit-concurrency.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Swift 6 Concurrency Audit: OpenClaw iOS App
|
||||
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~15K LOC)
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** Concurrency Auditor Agent
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Files audited | 63 |
|
||||
| `@MainActor` classes | 8 |
|
||||
| `actor` types | 1 (`CameraController`) |
|
||||
| `@unchecked Sendable` types | 9 |
|
||||
| `@preconcurrency` imports | 2 (`UserNotifications`, `WatchConnectivity`) |
|
||||
| `@preconcurrency` conformances | 2 (`UNUserNotificationCenterDelegate`, `NetServiceDelegate`) |
|
||||
| `nonisolated(unsafe)` usages | 1 |
|
||||
| `NSLock` usages | 6 |
|
||||
| `DispatchQueue` usages | 7 |
|
||||
| `objc_sync_enter/exit` usages | 1 |
|
||||
| `CheckedContinuation` usages | ~25 |
|
||||
| `@Observable` (Observation framework) types | 6 |
|
||||
| `ObservableObject` types | 0 |
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
The codebase is in **good shape for Swift 6 strict concurrency**. The major model types use `@MainActor` + `@Observable` (Observation framework), there are zero `ObservableObject` usages, and the actor model is applied consistently. There are no `@Sendable` annotations missing on closure parameters in any obvious way, and the use of `@unchecked Sendable` is confined to genuine low-level synchronization wrappers. However, there are several areas that warrant attention.
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `GatewayTLSFingerprintProbe` uses `objc_sync_enter` + `@unchecked Sendable` with unsynchronized `didFinish` read
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:992-1058`
|
||||
**Severity:** Critical (potential data race)
|
||||
|
||||
```swift
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private var didFinish = false // line 996
|
||||
private var session: URLSession? // line 997
|
||||
private var task: URLSessionWebSocketTask? // line 998
|
||||
...
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self) // line 1039
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `start()` method (line 1006) reads and writes `self.session` and `self.task` without any lock. The `DispatchQueue.global().asyncAfter` timeout on line 1016 calls `finish()` from a background queue while `start()` sets properties on the caller's thread. Additionally, `URLSession` delegate callbacks arrive on an arbitrary delegate queue (nil was passed for `delegateQueue`), which means `urlSession(_:didReceive:completionHandler:)` and `finish()` can race.
|
||||
|
||||
**Recommendation:** Replace `objc_sync_enter/exit` with `NSLock` or `OSAllocatedUnfairLock`. Ensure all mutable state (`didFinish`, `session`, `task`) is accessed under the lock. Better yet, convert to an `actor` since this is a short-lived async operation. Alternatively, use `OSAllocatedUnfairLock<State>` wrapping a struct.
|
||||
|
||||
---
|
||||
|
||||
### C-2: `PhotoCaptureDelegate` and `MovieFileDelegate` lack synchronization on `didResume`
|
||||
|
||||
**File:** `Camera/CameraController.swift:260-339`
|
||||
**Severity:** Critical (potential double continuation resume)
|
||||
|
||||
```swift
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false // NOT thread-safe
|
||||
|
||||
func photoOutput(...) {
|
||||
guard !self.didResume else { return } // line 273
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
func photoOutput(...didFinishCaptureFor...) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return } // line 303
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `AVCapturePhotoCaptureDelegate` callbacks can arrive on different queues. The `didResume` flag is a plain `Bool` with no synchronization. If `didFinishProcessingPhoto` and `didFinishCaptureFor` are called concurrently (possible under certain error conditions), both could read `didResume` as `false` and resume the continuation twice, which is a fatal crash in debug builds and undefined behavior in release.
|
||||
|
||||
**Recommendation:** Protect `didResume` with `OSAllocatedUnfairLock<Bool>` or `NSLock`. The same issue applies to `MovieFileDelegate` on line 309.
|
||||
|
||||
---
|
||||
|
||||
### C-3: `GatewayDiagnostics.logWritesSinceCheck` is `nonisolated(unsafe)` static var
|
||||
|
||||
**File:** `Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** Critical (data race)
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
**Issue:** This counter is read and written inside `queue.async {}` blocks on `GatewayDiagnostics.queue`, but `nonisolated(unsafe)` tells the compiler to skip checking. The access is actually serialized by the private `DispatchQueue`, so it is functionally safe -- however, `nonisolated(unsafe)` is a red flag for Swift 6 audits because it permanently suppresses the compiler's data-race safety checks.
|
||||
|
||||
**Recommendation:** Replace with proper synchronization visible to the compiler. Either:
|
||||
1. Make it a local variable inside the `DispatchQueue` closure scope, or
|
||||
2. Wrap in `OSAllocatedUnfairLock<Int>` or a dedicated `actor`, or
|
||||
3. Since all accesses are on `GatewayDiagnostics.queue`, convert to a `@Sendable`-safe pattern that doesn't require `nonisolated(unsafe)`.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: `ScreenRecordService` is `@unchecked Sendable` but holds no state -- its inner `CaptureState` synchronizes via NSLock but `UncheckedSendableBox` silences Sendable checks
|
||||
|
||||
**File:** `Screen/ScreenRecordService.swift:4-11`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UncheckedSendableBox` wraps **any** `T` (including non-Sendable types like `CMSampleBuffer`) and marks it `@unchecked Sendable`. This is used to pass `CMSampleBuffer` across threads in the capture handler. While `CMSampleBuffer` is effectively thread-safe for read-only access, this pattern silences the compiler completely and could mask future issues if the box is used for other types.
|
||||
|
||||
**Recommendation:** Use `nonisolated(unsafe) let value: T` instead if on Swift 6.2+, or document the specific thread-safety invariant. Consider constraining `T: Sendable` on the generic and handling `CMSampleBuffer` separately with a targeted unsafe annotation.
|
||||
|
||||
### H-2: `WatchMessagingService` is `@unchecked Sendable` with mutable `replyHandler` protected only by NSLock
|
||||
|
||||
**File:** `Services/WatchMessagingService.swift:23-28`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
```
|
||||
|
||||
**Issue:** While the `replyHandler` is properly protected by `NSLock`, the `session` property (`WCSession?`) is accessed from both the main thread (via delegate callbacks forwarded with `@preconcurrency`) and potentially from WatchConnectivity's internal threads. The `WCSession` properties like `isPaired`, `isWatchAppInstalled`, `isReachable` are read in `status(for:)` without synchronization and could race with delegate callbacks.
|
||||
|
||||
**Recommendation:** Convert to an `actor` or ensure all `WCSession` property reads happen on a specific isolation context. The lock properly protects `replyHandler`, so this is a moderate risk.
|
||||
|
||||
### H-3: `LocationService` stores `CheckedContinuation` as instance vars without synchronization between `nonisolated` delegate callbacks and `@MainActor` methods
|
||||
|
||||
**File:** `Location/LocationService.swift:13-14, 136-176`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
```
|
||||
|
||||
The delegate methods are marked `nonisolated`:
|
||||
```swift
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `nonisolated` delegate methods create `Task { @MainActor in }` to hop back to the main actor before accessing continuations. This is the correct pattern. However, there is a subtle race: if two delegate callbacks arrive in rapid succession, both could queue `@MainActor` tasks, and the second one would find the continuation already `nil`. This is handled (the `if let` guards), but the pattern is fragile. More importantly, `CLLocationManager` requires its delegate methods to be called on the queue it was created on. Since the class is `@MainActor`, the manager is created on main, and iOS should deliver delegate callbacks on main -- making the `nonisolated` annotation somewhat misleading.
|
||||
|
||||
**Recommendation:** Since `CLLocationManager` delivers callbacks on the thread/queue of the delegate's assigned queue (main in this case), the `nonisolated` annotation is technically unnecessary and may confuse future maintainers. Consider removing `nonisolated` and letting `@MainActor` inheritance apply. This would also let the compiler verify the continuation access is safe.
|
||||
|
||||
### H-4: `LiveNotificationCenter` is `@unchecked Sendable` wrapping a non-Sendable `UNUserNotificationCenter`
|
||||
|
||||
**File:** `Services/NotificationService.swift:18-58`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` is not `Sendable`. Wrapping it in `@unchecked Sendable` silences the compiler. In practice, `UNUserNotificationCenter.current()` returns a singleton that is thread-safe, so this is functionally fine -- but the compiler cannot verify this.
|
||||
|
||||
**Recommendation:** This is acceptable given `UNUserNotificationCenter.current()` is a thread-safe singleton. Document the invariant with a comment explaining why `@unchecked Sendable` is safe here. Alternatively, access the center via `UNUserNotificationCenter.current()` each time instead of storing it.
|
||||
|
||||
### H-5: `NetworkStatusService` is `@unchecked Sendable` but has no mutable state
|
||||
|
||||
**File:** `Device/NetworkStatusService.swift:5`
|
||||
**Severity:** High (misleading annotation)
|
||||
|
||||
```swift
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
```
|
||||
|
||||
**Issue:** `NetworkStatusService` has no stored properties at all. It creates `NWPathMonitor` locally in each method call. The `@unchecked Sendable` is unnecessary because a stateless final class is inherently `Sendable`.
|
||||
|
||||
**Recommendation:** Remove `@unchecked` -- just conform to `Sendable` directly. The class has no mutable state and is `final`, so it qualifies for automatic Sendable conformance.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `TalkModeManager` `pttCompletion` continuation stored as instance var could leak
|
||||
|
||||
**File:** `Voice/TalkModeManager.swift:43`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private var pttCompletion: CheckedContinuation<OpenClawTalkPTTStopPayload, Never>?
|
||||
```
|
||||
|
||||
**Issue:** If `pttCompletion` is set but the manager is deinitialized or the PTT session is interrupted without resuming it, the continuation will leak. `CheckedContinuation` logs a warning in debug builds when it is never resumed, and in production the caller will hang indefinitely.
|
||||
|
||||
**Recommendation:** Add a `deinit` or cleanup path that resumes `pttCompletion` with a default/error value. Also verify that all code paths that set `pttCompletion` eventually resume it (including error paths, cancellation, and mode changes).
|
||||
|
||||
### M-2: Heavy use of `Task { @MainActor in }` hops in code that is already `@MainActor`
|
||||
|
||||
**Files:** Multiple (OpenClawApp.swift:30-47, NodeAppModel.swift:179-207, etc.)
|
||||
**Severity:** Medium (performance/clarity)
|
||||
|
||||
```swift
|
||||
// In OpenClawAppDelegate which is already @MainActor:
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** When code is already on `@MainActor`, creating `Task { @MainActor in }` is redundant in terms of isolation but does defer execution to the next event loop tick. If the intent is immediate execution, this is a performance anti-pattern. If the intent is deferral, it should be documented.
|
||||
|
||||
**Recommendation:** Where immediate execution is intended, call the method directly. Where deferral is intentional, add a comment explaining why. In Swift 6.2 with `nonisolated(nonsending)` defaults, these patterns will behave differently.
|
||||
|
||||
### M-3: `GatewayDiscoveryModel` browser callbacks use closures that capture `self` without explicit `@Sendable`
|
||||
|
||||
**File:** `Gateway/GatewayDiscoveryModel.swift:60-96`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
...
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state // MainActor state access
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { ... }
|
||||
```
|
||||
|
||||
**Issue:** These closures capture `self` (a `@MainActor` `@Observable` class) and mutate its state. If `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches these callbacks on a background queue (which NWBrowser does by default), this would be a main-actor isolation violation. The callbacks access `@MainActor`-isolated properties without explicitly hopping to the main actor.
|
||||
|
||||
**Recommendation:** Verify that `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches callbacks on the main queue. If not, wrap the callback bodies in `Task { @MainActor in ... }` or `await MainActor.run { ... }`. This is a potential data race if callbacks arrive off-main.
|
||||
|
||||
### M-4: `withObservationTracking` + `onChange` pattern in `GatewayConnectionController.observeDiscovery()` could miss updates
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery() // re-register
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `onChange` handler in `withObservationTracking` fires at most once per registration. The recursive re-registration inside `Task { @MainActor in }` means there is a window between when the `onChange` fires and when the new tracking is registered where changes could be missed. In practice, the `Task` hop is fast, but under heavy load or if the main actor queue is busy, rapid changes to `discovery.gateways` could be dropped.
|
||||
|
||||
**Recommendation:** This is a known limitation of `withObservationTracking` outside SwiftUI. Consider using `AsyncStream` or `Combine` publisher from the discovery model instead, which provides continuous observation without re-registration gaps.
|
||||
|
||||
### M-5: `GatewayServiceResolver` does not protect `didFinish` flag with a lock
|
||||
|
||||
**File:** `Gateway/GatewayServiceResolver.swift:9, 41-47`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private var didFinish = false
|
||||
|
||||
private func finish(result: ...) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NetServiceDelegate` callbacks can theoretically arrive on multiple threads (depending on how the service is scheduled). The `didFinish` flag is not synchronized. If `netServiceDidResolveAddress` and `netService(_:didNotResolve:)` are called concurrently, `finish` could be called twice.
|
||||
|
||||
**Recommendation:** Add `NSLock` protection or use `OSAllocatedUnfairLock<Bool>` for `didFinish`. Alternatively, ensure the service is always scheduled on the main run loop (which `BonjourServiceResolverSupport.start` may already do).
|
||||
|
||||
### M-6: `ContactsService`, `CalendarService`, `RemindersService`, `MotionService`, `PhotoLibraryService` conform to `Sendable` protocols but are plain classes without actor isolation
|
||||
|
||||
**Files:** Various service files
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class ContactsService: ContactsServicing { ... }
|
||||
// ContactsServicing: Sendable
|
||||
```
|
||||
|
||||
**Issue:** These classes have no mutable stored properties and are `final`, which technically makes them safe to mark `Sendable`. However, they don't explicitly declare `Sendable` conformance -- they inherit it through their protocol conformances (`ContactsServicing: Sendable`). The Swift 6 compiler will flag this because a `final class` without explicit `Sendable` or `@unchecked Sendable` conformance cannot implicitly satisfy `Sendable` requirements from protocols unless it is provably safe (no mutable state).
|
||||
|
||||
**Recommendation:** Since these classes are stateless and `final`, add explicit `: Sendable` conformance or verify they compile cleanly under strict concurrency.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@preconcurrency import UserNotifications` and `@preconcurrency import WatchConnectivity` suppress Sendable warnings
|
||||
|
||||
**Files:** `OpenClawApp.swift:7`, `Services/WatchMessagingService.swift:4`
|
||||
**Severity:** Low
|
||||
|
||||
**Issue:** `@preconcurrency` imports suppress sendability diagnostics for types from those modules. As Apple updates these frameworks for Sendable conformance in newer SDKs, the `@preconcurrency` should be removed to benefit from the compiler's checks.
|
||||
|
||||
**Recommendation:** Periodically check if these frameworks have been updated with Sendable annotations in newer Xcode versions and remove `@preconcurrency` when possible.
|
||||
|
||||
### L-2: `VoiceWakeManager.makeRecognitionResultHandler()` returns `@Sendable` closure that captures `[weak self]` correctly
|
||||
|
||||
**File:** `Voice/VoiceWakeManager.swift:301-313`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
The recognition result handler correctly captures `[weak self]` and hops to `@MainActor` before accessing any state. This is a good pattern.
|
||||
|
||||
### L-3: `CameraController` is an `actor` -- exemplary usage
|
||||
|
||||
**File:** `Camera/CameraController.swift:5`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
`CameraController` is the only `actor` in the codebase. It properly uses `nonisolated static` for pure functions and `async` for all state-mutating operations. This is a model for how other services could be structured.
|
||||
|
||||
### L-4: Several `Task { }` in `@MainActor` context don't explicitly annotate `@MainActor`
|
||||
|
||||
**Files:** Multiple
|
||||
**Severity:** Low
|
||||
|
||||
```swift
|
||||
// Inside @MainActor class:
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** In Swift 6.0, an unstructured `Task { }` created from `@MainActor` context inherits the actor context. However, in Swift 6.2 with `nonisolated(nonsending)` defaults, this behavior may change. Explicitly annotating `Task { @MainActor in }` makes the intent clear and forward-compatible.
|
||||
|
||||
**Recommendation:** Add explicit `@MainActor` annotation to `Task { }` blocks in `@MainActor` types where main-actor isolation is required.
|
||||
|
||||
### L-5: Consider migrating `NSLock` to `OSAllocatedUnfairLock` for better performance
|
||||
|
||||
**Files:** Multiple (6 usages)
|
||||
**Severity:** Low
|
||||
|
||||
`OSAllocatedUnfairLock` (available since iOS 16) is faster than `NSLock` for short critical sections. The existing `NSLock` usages in `AudioBufferQueue`, `NotificationInvokeLatch`, `CaptureState`, etc. are all protecting brief property accesses and would benefit from the switch.
|
||||
|
||||
**Recommendation:** Migrate `NSLock` to `OSAllocatedUnfairLock` where deployment target allows (iOS 16+). `TCPProbe.swift` already uses `OSAllocatedUnfairLock` -- apply the same pattern to other files.
|
||||
|
||||
### L-6: `NodeAppModel` is very large (~1500+ lines) which makes concurrency reasoning difficult
|
||||
|
||||
**File:** `Model/NodeAppModel.swift`
|
||||
**Severity:** Low (maintainability)
|
||||
|
||||
**Issue:** The large file size with many Task/async operations, multiple gateway sessions, and deeply nested closures makes it harder to reason about concurrency invariants. All state is `@MainActor` which is safe, but the complexity makes it harder to verify no accidental non-isolated access exists.
|
||||
|
||||
**Recommendation:** Consider splitting into smaller focused files (already noted with `NodeAppModel+Canvas.swift` and `NodeAppModel+WatchNotifyNormalization.swift` extensions). Further decomposition would improve auditability.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Found
|
||||
|
||||
1. **Consistent `@MainActor` + `@Observable` usage**: All major model types (`NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `TalkModeManager`, `VoiceWakeManager`, `ScreenController`) use the Observation framework with `@MainActor` isolation. Zero `ObservableObject` usages.
|
||||
|
||||
2. **Zero `@Sendable` protocol conformance issues**: All service protocols (`CameraServicing`, `LocationServicing`, `DeviceStatusServicing`, etc.) correctly require `Sendable`.
|
||||
|
||||
3. **`CameraController` as `actor`**: Properly models concurrent camera access.
|
||||
|
||||
4. **`@Sendable` closures in callback APIs**: Callback closures (e.g., `onCommand` in `VoiceWakeManager`, `replyHandler` in `WatchMessagingService`) are properly annotated `@Sendable`.
|
||||
|
||||
5. **`CheckedContinuation` usage**: All continuation usages properly handle the single-resume invariant with `didResume`/`finished` flags (though some lack synchronization -- see C-2 and M-5).
|
||||
|
||||
6. **No `DispatchQueue.main.async` for UI updates**: All UI-related state mutations go through `@MainActor` or `Task { @MainActor in }`, not legacy GCD patterns.
|
||||
|
||||
7. **`ThrowingContinuationSupport.resumeVoid`**: Custom helper for void continuations reduces boilerplate and potential mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6.2 / iOS 26 Forward-Compatibility Notes
|
||||
|
||||
1. **`nonisolated(nonsending)` default**: Several `nonisolated` functions and closures may need `@concurrent` annotation if they are intended to run off the caller's actor. Review all `nonisolated` methods.
|
||||
|
||||
2. **Default `@MainActor` isolation**: If the project opts into Swift 6.2's `MainActorByDefault`, most explicit `@MainActor` annotations become redundant. The current architecture is well-positioned for this.
|
||||
|
||||
3. **`@preconcurrency` removal**: As Apple frameworks adopt Sendable, remove `@preconcurrency` imports for `UserNotifications` and `WatchConnectivity`.
|
||||
|
||||
4. **`sending` parameter keyword**: New `sending` keyword in Swift 6.2 may replace some `@Sendable` closure annotations for parameters that are consumed (not stored).
|
||||
376
apps/ios/audit-security.md
Normal file
376
apps/ios/audit-security.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# iOS App Security, Networking & Performance Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/`, `apps/shared/OpenClawKit/Sources/` (security-relevant shared code), `apps/ios/project.yml`, entitlements
|
||||
**Auditor:** Security & Performance Audit Agent
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Posture Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a **generally strong security posture** for a local-network gateway client. Key strengths include:
|
||||
|
||||
- **Keychain usage for credentials:** Gateway tokens, passwords, instance IDs, and API keys are stored in Keychain (not UserDefaults).
|
||||
- **TLS certificate pinning:** SHA-256 certificate fingerprint pinning is implemented for gateway WebSocket connections via `GatewayTLSPinningSession`.
|
||||
- **Trust-on-first-use (TOFU) with user confirmation:** New gateway TLS fingerprints require explicit user approval before trust is established.
|
||||
- **Deep link confirmation:** Agent deep links (the `openclaw://` URL scheme) require user confirmation before execution, with message length limits.
|
||||
- **Web view security:** The canvas WKWebView uses `.nonPersistent()` data store and validates that A2UI action messages originate only from trusted/local-network URLs.
|
||||
- **Input sanitization:** Consistent `.trimmingCharacters(in: .whitespacesAndNewlines)` throughout, input length limits on contacts/calendar/photos queries.
|
||||
- **Permission gating:** All hardware capabilities (camera, location, microphone, contacts, calendar, photos) check authorization status before access.
|
||||
- **No hardcoded secrets:** No API keys, tokens, or credentials are hardcoded in the source.
|
||||
- **Swift 6 strict concurrency:** Enabled project-wide (`SWIFT_STRICT_CONCURRENCY: complete`), reducing data race risks.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Severity Findings
|
||||
|
||||
*No critical vulnerabilities identified.*
|
||||
|
||||
The app does not store plaintext passwords in UserDefaults, does not embed secrets, does not disable ATS globally, and does not allow arbitrary code execution from untrusted sources. The attack surface is primarily local-network, which limits remote exploitation vectors.
|
||||
|
||||
---
|
||||
|
||||
## 3. High Severity Findings
|
||||
|
||||
### H-1: TLS Fingerprints Stored in UserDefaults Instead of Keychain
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:19-38`
|
||||
**Severity:** HIGH
|
||||
|
||||
`GatewayTLSStore` stores TLS certificate fingerprints in `UserDefaults(suiteName: "ai.openclaw.shared")`. While fingerprints themselves are not secrets, they serve as the trust anchor for the TLS pinning system. An attacker with access to the device backup (unencrypted iTunes/Finder backup) or a compromised app extension sharing the same suite could modify these fingerprints and redirect gateway connections to a malicious server.
|
||||
|
||||
**Exploit scenario:** An attacker with physical or backup access modifies the stored fingerprint for a known gateway stableID, then performs a MITM attack on the LAN. The app connects using the attacker's fingerprint as the expected pin.
|
||||
|
||||
**Recommended fix:** Store TLS fingerprints in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (matching the existing `KeychainStore` pattern). This prevents backup extraction and cross-device compromise.
|
||||
|
||||
---
|
||||
|
||||
### H-2: KeychainStore Update Path Does Not Set Accessibility Level
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/KeychainStore.swift:20-37`
|
||||
**Severity:** HIGH
|
||||
|
||||
In `saveString()`, when the item already exists (`SecItemUpdate` succeeds), the update does not set or enforce the `kSecAttrAccessible` attribute. Only new items (via `SecItemAdd`) get `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. If a Keychain item was originally created with a less restrictive accessibility level (e.g., during a migration or by an older version), it retains that weaker level after updates.
|
||||
|
||||
**Exploit scenario:** An older app version or a migration path creates a Keychain item without specifying `kSecAttrAccessible` (defaults to `kSecAttrAccessibleWhenUnlocked`). After upgrading, the item retains the old accessibility level, potentially making it accessible via iCloud Keychain sync.
|
||||
|
||||
**Recommended fix:** Before `SecItemUpdate`, delete and re-add the item with the correct accessibility attribute, or explicitly include `kSecAttrAccessible` in the update query attributes. Example:
|
||||
|
||||
```swift
|
||||
// Delete-then-add pattern for consistent accessibility
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-3: Gateway Connection Metadata in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:170-217`
|
||||
**Severity:** HIGH
|
||||
|
||||
Last-known gateway connection details (host, port, TLS flag, stableID, connection kind) are stored in `UserDefaults.standard`. This data reveals which gateway servers the user connects to, their network topology, and connection preferences. UserDefaults are included in unencrypted device backups and can be read by MDM profiles or forensic tools.
|
||||
|
||||
**Affected keys:** `gateway.last.kind`, `gateway.last.host`, `gateway.last.port`, `gateway.last.tls`, `gateway.last.stableID`, `gateway.manual.host`, `gateway.manual.port`, `gateway.manual.tls`, `gateway.manual.clientId`, `gateway.clientIdOverride.*`, `gateway.selectedAgentId.*`.
|
||||
|
||||
**Recommended fix:** Move gateway connection metadata that reveals network topology to Keychain or use `NSFileProtectionCompleteUntilFirstUserAuthentication` on a dedicated plist file in the app's data directory.
|
||||
|
||||
---
|
||||
|
||||
## 4. Medium Severity Findings
|
||||
|
||||
### M-1: `NSAllowsArbitraryLoadsInWebContent` Enabled
|
||||
|
||||
**File:** `apps/ios/project.yml:110`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```yaml
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
```
|
||||
|
||||
This disables ATS protections for WKWebView content. While necessary for the canvas to load user-specified URLs from the gateway (including local-network HTTP servers), it means the web view can load insecure HTTP resources. The `ScreenController.navigate()` method does filter out loopback URLs but does not enforce HTTPS for remote URLs.
|
||||
|
||||
**Exploit scenario:** A gateway instructs the canvas to load an HTTP URL on a public network. The content is intercepted/modified via MITM.
|
||||
|
||||
**Recommended fix:** This is largely an accepted risk given the product's design (canvas loads gateway-specified URLs). Consider adding a user-visible indicator when the canvas is loading non-HTTPS content, and log a warning.
|
||||
|
||||
---
|
||||
|
||||
### M-2: Diagnostic Log File Written to Documents Directory Without Protection
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:359-448`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`GatewayDiagnostics` writes logs to `Documents/openclaw-gateway.log`. The Documents directory is accessible via iTunes file sharing (if enabled) and is included in device backups. Log entries include timestamps and gateway connection events which could reveal usage patterns.
|
||||
|
||||
Logs are written with `privacy: .public` in the `os.Logger` calls, meaning they are also visible in `Console.app` sysdiagnose captures without redaction.
|
||||
|
||||
**Recommended fix:** Write diagnostic logs to `Library/Caches/` instead (excluded from backups), apply `NSFileProtectionCompleteUntilFirstUserAuthentication`, and consider using `privacy: .private` or `privacy: .auto` for log messages that may contain sensitive connection details.
|
||||
|
||||
---
|
||||
|
||||
### M-3: Environment Variable Fallback for ElevenLabs API Key
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/TalkModeManager.swift:991-992`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```swift
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
```
|
||||
|
||||
The talk mode manager reads API keys from environment variables as a fallback. While environment variables are not accessible to other apps on iOS, they persist in process memory and could be captured in crash reports. This pattern is more suitable for development/debugging and should not ship in production builds.
|
||||
|
||||
**Recommended fix:** Gate this fallback behind `#if DEBUG` to prevent production builds from reading API keys from environment variables.
|
||||
|
||||
---
|
||||
|
||||
### M-4: Instance ID Dual-Storage Creates Sync Risk
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:291-312`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`ensureStableInstanceID()` maintains the instance ID in both Keychain and UserDefaults (`node.instanceId`). If either store is cleared independently (e.g., Keychain reset during device restore without backup, or UserDefaults cleared by storage pressure), the sync logic may create a new UUID, effectively orphaning the device's gateway registration.
|
||||
|
||||
While this is a robustness concern rather than a direct vulnerability, an attacker who can clear UserDefaults (e.g., via an MDM-deployed configuration profile) could force a device identity reset.
|
||||
|
||||
**Recommended fix:** Designate Keychain as the single source of truth and only mirror to UserDefaults for read convenience. Document the recovery flow for identity reset.
|
||||
|
||||
---
|
||||
|
||||
### M-5: No Rate Limiting on Deep Link Agent Prompts
|
||||
|
||||
**File:** `apps/ios/Sources/Model/NodeAppModel.swift:43-45, 92`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The `IOSDeepLinkAgentPolicy` defines `maxMessageChars = 20000` and `maxUnkeyedConfirmChars = 240`, and there is a `lastAgentDeepLinkPromptAt` timestamp. However, without a minimum interval check, a malicious webpage or app could rapidly fire `openclaw://` deep links, creating a flood of confirmation dialogs that degrade UX and potentially trick users into accepting a malicious prompt through fatigue.
|
||||
|
||||
**Recommended fix:** Enforce a minimum interval (e.g., 5 seconds) between successive deep link prompts, silently dropping duplicates. The `lastAgentDeepLinkPromptAt` field exists but its enforcement should be verified.
|
||||
|
||||
---
|
||||
|
||||
### M-6: QR Code Parsing Accepts Multiple Formats Without Strict Validation
|
||||
|
||||
**File:** `apps/ios/Sources/Onboarding/QRScannerView.swift:63-85`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The QR scanner tries two parsing strategies: `GatewayConnectDeepLink.fromSetupCode(payload)` (base64url JSON) and `DeepLinkParser.parse(url)` (URL format). The `GatewaySetupCode.decode()` method (`apps/ios/Sources/Gateway/GatewaySetupCode.swift`) accepts arbitrary base64-encoded JSON payloads that decode into `GatewaySetupPayload`. There is no signature verification or HMAC on the QR code content.
|
||||
|
||||
**Exploit scenario:** An attacker places a malicious QR code that encodes a gateway URL pointing to their controlled server. When scanned, the user is prompted to connect to the attacker's gateway.
|
||||
|
||||
**Mitigating factors:** The TLS trust prompt still fires for new gateways, requiring explicit user approval of the certificate fingerprint.
|
||||
|
||||
**Recommended fix:** Consider adding an HMAC or signing mechanism to QR setup codes so the app can verify they were generated by the user's own gateway. At minimum, clearly display the gateway URL/host to the user during the onboarding flow.
|
||||
|
||||
---
|
||||
|
||||
### M-7: WebSocket Maximum Message Size Set to 16 MB
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:55`
|
||||
**Severity:** MEDIUM (Performance/DoS)
|
||||
|
||||
```swift
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
```
|
||||
|
||||
A malicious or compromised gateway could send a 16 MB WebSocket message, causing a significant memory spike on the iOS device.
|
||||
|
||||
**Recommended fix:** Evaluate whether 16 MB is necessary. Consider progressive parsing or streaming for large payloads. Add a sanity check on incoming message size.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low Severity Findings
|
||||
|
||||
### L-1: Location Data Sent Over Gateway WebSocket Without End-to-End Encryption
|
||||
|
||||
**File:** `apps/ios/Sources/Location/SignificantLocationMonitor.swift:21-38`
|
||||
**Severity:** LOW
|
||||
|
||||
Significant location updates (lat, lon, accuracy) are sent as JSON over the gateway WebSocket. While the WebSocket uses TLS (wss://), the gateway server itself can read the location data in plaintext. This is by design (the gateway processes location for hooks), but users should be informed that location data is accessible to the gateway process.
|
||||
|
||||
**Recommended fix:** Document this clearly in privacy documentation. Consider allowing users to configure location precision (rounding to neighborhood-level vs. exact coordinates).
|
||||
|
||||
---
|
||||
|
||||
### L-2: Camera Photo/Video Base64 Encoding in Memory
|
||||
|
||||
**Files:** `apps/ios/Sources/Camera/CameraController.swift:84`, `apps/ios/Sources/Media/PhotoLibraryService.swift:105`
|
||||
**Severity:** LOW
|
||||
|
||||
Camera captures and photo library images are base64-encoded in memory before being sent over the gateway. For large images (up to 1600px wide at 0.9 quality), this means the raw image data plus the base64 string (33% larger) coexist in memory briefly.
|
||||
|
||||
**Mitigating factors:** The app already clamps max width to 1600px and applies quality compression. Temporary files are cleaned up via `defer` blocks.
|
||||
|
||||
**Recommended fix:** Consider streaming base64 encoding or using a memory-mapped approach for very large payloads. Current implementation is adequate for the existing size limits.
|
||||
|
||||
---
|
||||
|
||||
### L-3: Screen Recording Output Path User-Controllable
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift:103-109`
|
||||
**Severity:** LOW
|
||||
|
||||
The `makeOutputURL` method accepts an optional `outPath` parameter. If this comes from a gateway command, a malicious gateway could specify a path outside the app's sandbox (which iOS would block) or overwrite files within the sandbox.
|
||||
|
||||
**Mitigating factors:** iOS sandbox prevents writing outside the app container. The `defer` cleanup in the caller should handle temporary files.
|
||||
|
||||
**Recommended fix:** Validate that `outPath` is within the app's temporary or documents directory before using it. Reject absolute paths that don't start with the app's known writable directories.
|
||||
|
||||
---
|
||||
|
||||
### L-4: Voice Wake Preferences Stored in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakePreferences.swift:23-29`
|
||||
**Severity:** LOW
|
||||
|
||||
Trigger words and voice wake enabled state are stored in `UserDefaults.standard`. While trigger words are not sensitive per se, they reveal user behavior patterns.
|
||||
|
||||
**Recommended fix:** Acceptable for non-sensitive preferences. No action needed unless trigger words become user-configurable sensitive phrases.
|
||||
|
||||
---
|
||||
|
||||
### L-5: `nonisolated(unsafe)` in GatewayDiagnostics
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** LOW
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
This counter is accessed from the `DispatchQueue` without the lock that protects the file I/O. While this is a benign data race (used only for approximate frequency gating), it could theoretically cause the log size check to be skipped or double-triggered.
|
||||
|
||||
**Recommended fix:** Move the counter into the `queue.async` block or use an atomic counter.
|
||||
|
||||
---
|
||||
|
||||
### L-6: `objc_sync_enter` Used for Synchronization
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayTLSFingerprintProbe.finish()` uses `objc_sync_enter/exit` for synchronization. This is the Objective-C `@synchronized` primitive. While functional, modern Swift best practice prefers `OSAllocatedUnfairLock` (as used correctly in `TCPProbe`), `NSLock`, or actor isolation.
|
||||
|
||||
**Recommended fix:** Replace with `OSAllocatedUnfairLock` for consistency with the rest of the codebase.
|
||||
|
||||
---
|
||||
|
||||
### L-7: No Certificate Revocation Checking
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:59-96`
|
||||
**Severity:** LOW
|
||||
|
||||
The TLS pinning implementation checks the certificate fingerprint but does not perform OCSP or CRL revocation checking. For self-signed certificates (typical in local gateway setups), this is expected. For publicly-signed certificates, revocation checking would add defense in depth.
|
||||
|
||||
**Recommended fix:** For the current use case (self-signed gateway certs on LAN), this is acceptable. If public CA certificates are used in future, consider enabling revocation checking via `SecTrustSetOptions`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Concerns
|
||||
|
||||
### P-1: ISO8601DateFormatter Created Per Log Entry
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:422-424`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayDiagnostics.log()` creates a new `ISO8601DateFormatter` for every log call. `ISO8601DateFormatter` is relatively expensive to initialize.
|
||||
|
||||
**Recommended fix:** Cache a static formatter instance (thread-safety is acceptable for `ISO8601DateFormatter` as it is immutable after configuration).
|
||||
|
||||
---
|
||||
|
||||
### P-2: Observation Tracking Re-registration Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** LOW
|
||||
|
||||
The `observeDiscovery()` method uses `withObservationTracking` and recursively calls itself in the `onChange` closure. This is the standard Swift Observation pattern, but each change creates a new `Task` and re-registers tracking. Under rapid discovery state changes, this could create a burst of Task allocations.
|
||||
|
||||
**Mitigating factors:** Discovery state changes are infrequent (Bonjour events).
|
||||
|
||||
**Recommended fix:** Consider debouncing or coalescing rapid state changes.
|
||||
|
||||
---
|
||||
|
||||
### P-3: Synchronous Photo Library Access
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift:69-71`
|
||||
**Severity:** MEDIUM (Performance)
|
||||
|
||||
```swift
|
||||
options.isSynchronous = true
|
||||
```
|
||||
|
||||
`PHImageManager.requestImage` is called synchronously, which blocks the calling thread until the image is loaded and decoded. For network-backed assets (iCloud Photo Library), this could block for seconds.
|
||||
|
||||
**Recommended fix:** Use asynchronous image loading with a continuation wrapper to avoid blocking.
|
||||
|
||||
---
|
||||
|
||||
### P-4: Camera Clip Base64 Encoding of Video Data
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift:89-140`
|
||||
**Severity:** LOW
|
||||
|
||||
Video clips (up to 60 seconds) are fully loaded into memory as `Data` and then base64-encoded. A 60-second medium-quality MP4 could be 10-30 MB, producing a 13-40 MB base64 string in memory.
|
||||
|
||||
**Mitigating factors:** Default duration is 3 seconds, keeping typical payloads small. The 60-second max is enforced at `CameraController.clampDurationMs`.
|
||||
|
||||
**Recommended fix:** Consider a streaming upload mechanism for clips longer than ~10 seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7. OWASP Mobile Top 10 2024 Checklist
|
||||
|
||||
| # | OWASP Category | Status | Notes |
|
||||
|---|---------------|--------|-------|
|
||||
| M1 | Improper Credential Usage | **PASS** | Credentials stored in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. No hardcoded secrets. API keys from env vars gated to dev builds (recommended). |
|
||||
| M2 | Inadequate Supply Chain Security | **PASS** | Dependencies are version-pinned via Package.resolved. SwiftLint and SwiftFormat enforce code quality. |
|
||||
| M3 | Insecure Authentication/Authorization | **PASS** | Gateway authentication uses token + password stored in Keychain. TLS pinning prevents MITM. Deep links require user confirmation. |
|
||||
| M4 | Insufficient Input/Output Validation | **PASS** | Input trimming and length limits applied consistently. QR code parsing has two validated paths. Calendar/contacts sanitize inputs. |
|
||||
| M5 | Insecure Communication | **PASS with notes** | TLS required for non-loopback connections. Certificate pinning implemented. `NSAllowsArbitraryLoadsInWebContent` allows HTTP in web views (accepted risk for canvas). |
|
||||
| M6 | Inadequate Privacy Controls | **PASS with notes** | All sensitive permissions have usage descriptions. Location data sent to gateway in plaintext over TLS. Photo library access checks authorization. Logging uses `privacy: .public` for some potentially sensitive data. |
|
||||
| M7 | Insufficient Binary Protections | **N/A** | Standard Xcode compilation. No jailbreak detection implemented (acceptable for non-financial app). |
|
||||
| M8 | Security Misconfiguration | **PASS with notes** | TLS fingerprints in UserDefaults (H-1). Gateway metadata in UserDefaults (H-3). Entitlements minimal (only `aps-environment`). |
|
||||
| M9 | Insecure Data Storage | **PASS with notes** | Credentials in Keychain (good). Gateway connection metadata in UserDefaults (H-3). Diagnostic logs in Documents directory (M-2). |
|
||||
| M10 | Insufficient Cryptography | **PASS** | SHA-256 for certificate fingerprinting via CryptoKit. No custom/weak crypto implementations. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of Recommendations by Priority
|
||||
|
||||
### Immediate (High)
|
||||
1. **H-1:** Move TLS fingerprint storage from UserDefaults to Keychain
|
||||
2. **H-2:** Fix KeychainStore to enforce accessibility level on updates (delete + re-add)
|
||||
3. **H-3:** Move gateway connection metadata out of UserDefaults
|
||||
|
||||
### Short-term (Medium)
|
||||
4. **M-3:** Gate `ELEVENLABS_API_KEY` env var fallback behind `#if DEBUG`
|
||||
5. **M-2:** Move diagnostic logs to Caches directory, apply file protection
|
||||
6. **M-5:** Enforce minimum interval between deep link prompts
|
||||
7. **M-6:** Add HMAC/signature to QR setup codes
|
||||
8. **M-7:** Evaluate reducing WebSocket max message size from 16 MB
|
||||
9. **P-3:** Convert synchronous photo library loading to async
|
||||
|
||||
### Long-term (Low / Hardening)
|
||||
10. **L-6:** Replace `objc_sync_enter` with `OSAllocatedUnfairLock`
|
||||
11. **L-3:** Validate screen recording output paths
|
||||
12. **P-1:** Cache ISO8601DateFormatter instances
|
||||
13. **M-1:** Add indicator for non-HTTPS canvas content
|
||||
14. **L-5:** Fix `nonisolated(unsafe)` data race in log counter
|
||||
|
||||
---
|
||||
|
||||
## 9. Positive Security Patterns Worth Preserving
|
||||
|
||||
- **TOFU with explicit user confirmation** for TLS fingerprints is a pragmatic and user-friendly approach for self-signed certificates.
|
||||
- **Dual WebSocket sessions** (node + operator) with separate role scoping provides good privilege separation.
|
||||
- **`websiteDataStore = .nonPersistent()`** for the canvas WKWebView prevents session data leakage.
|
||||
- **Origin validation in `CanvasA2UIActionMessageHandler`** (checking `isTrustedCanvasUIURL` and `isLocalNetworkCanvasURL`) is a strong defense against arbitrary web content triggering actions.
|
||||
- **Loopback URL rejection** in `ScreenController.navigate()` prevents SSRF-like attacks from the gateway.
|
||||
- **Autoconnect only to previously trusted gateways** (stored TLS pin required) prevents connecting to rogue gateways after TOFU.
|
||||
- **Permission checks before hardware access** with clear error messages is well-implemented.
|
||||
- **`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`** is the correct Keychain accessibility level for this use case (device-local, available after first unlock for background operation).
|
||||
318
apps/ios/audit-uiux.md
Normal file
318
apps/ios/audit-uiux.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# iOS App UI/UX & Accessibility Audit
|
||||
|
||||
**Audit Date:** 2026-03-02
|
||||
**Scope:** All SwiftUI view files in `apps/ios/Sources/`
|
||||
**Reference Standards:** Apple HIG (iOS 26), WCAG 2.1 AA, Liquid Glass design language, SwiftUI accessibility best practices
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Health Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a well-structured SwiftUI codebase with several accessibility-conscious patterns already in place. The app uses the modern `@Observable` / `Observation` framework consistently, respects `accessibilityReduceMotion`, responds to `colorSchemeContrast`, and provides accessibility labels on key interactive elements. However, there are significant gaps in Dynamic Type support, localization readiness, haptic feedback, and iPad adaptivity that should be addressed before the next major release.
|
||||
|
||||
**Strengths:**
|
||||
- Good use of `@Environment(\.accessibilityReduceMotion)` in animation-heavy views (RootTabs, StatusPill)
|
||||
- `StatusGlassCard` correctly responds to `colorSchemeContrast` for increased visibility
|
||||
- `StatusPill` has proper `accessibilityLabel`, `accessibilityValue`, and `accessibilityHint`
|
||||
- `TalkOrbOverlay` uses `accessibilityElement(children: .combine)` to present a single VoiceOver element
|
||||
- Consistent use of `@Observable` macro (Observation framework) over legacy `ObservableObject`
|
||||
- Glass material effects on overlays (`.ultraThinMaterial`) with light/dark mode awareness
|
||||
|
||||
**Weaknesses:**
|
||||
- Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize` environment usage)
|
||||
- Zero localization infrastructure (no `NSLocalizedString`, `String(localized:)`, or `.strings` files)
|
||||
- Zero haptic feedback across the entire app
|
||||
- Several views lack accessibility labels entirely
|
||||
- Hardcoded dimensions in TalkOrbOverlay will break on small screens
|
||||
- SettingsTab is a monolithic ~650 LOC file
|
||||
- No iPad-specific layout adaptations
|
||||
- `RootCanvas` voiceWakeToast animation does not respect `reduceMotion` (unlike `RootTabs`)
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: RootCanvas animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:159-167`
|
||||
**Description:** The `voiceWakeToastText` animation in `RootCanvas` uses hardcoded `.spring()` and `.easeOut()` animations without checking `@Environment(\.accessibilityReduceMotion)`. The sibling `RootTabs` view correctly guards the same toast animation with `reduceMotion ? .none : .spring(...)`.
|
||||
|
||||
**Impact:** Users who require reduced motion will see unexpected animations in the canvas view.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// In RootCanvas, add the environment property:
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Then guard animations:
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
// ...
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
```
|
||||
|
||||
### C-2: TalkOrbOverlay perpetual animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:15-26`
|
||||
**Description:** The pulsing ring animations use `.repeatForever(autoreverses: false)` without checking `reduceMotion`. These are high-frequency, continuous animations that can cause discomfort for users with vestibular disorders.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Replace pulse animations with:
|
||||
if !reduceMotion {
|
||||
Circle()
|
||||
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||
}
|
||||
```
|
||||
|
||||
### C-3: CameraFlashOverlay has no accessibility announcement
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:405-429`
|
||||
**Description:** `CameraFlashOverlay` flashes the screen white at 85% opacity. VoiceOver users have no indication that a photo was taken. There is no `AccessibilityNotification.Announcement` posted, and the flash itself could trigger photosensitive reactions.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// Post an accessibility announcement:
|
||||
AccessibilityNotification.Announcement("Photo captured").post()
|
||||
|
||||
// Add prefers-reduced-motion check to skip or soften the flash:
|
||||
if reduceMotion {
|
||||
// Skip flash, or use subtle opacity change
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Zero Dynamic Type support across the entire app
|
||||
|
||||
**Files:** All view files in `Sources/`
|
||||
**Description:** No view uses `@ScaledMetric`, `@Environment(\.dynamicTypeSize)`, or `ContentSizeCategory`. All hardcoded font sizes and dimensions (e.g., `font(.system(size: 16))` in `OverlayButton`, `font(.system(size: 12))` in monospaced debug text, `frame(width: 320, height: 320)` in TalkOrbOverlay) will not scale with the user's preferred text size. Apple's HIG strongly recommends supporting Dynamic Type for all text.
|
||||
|
||||
**Key locations:**
|
||||
- `Sources/RootCanvas.swift:358` - OverlayButton uses fixed `size: 16`
|
||||
- `Sources/Voice/TalkOrbOverlay.swift:16,23,39` - Fixed 320pt and 190pt circles
|
||||
- `Sources/Status/StatusPill.swift:52` - Fixed `width: 9, height: 9` indicator dot
|
||||
- `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:24` - Fixed `font(.callout)`
|
||||
- `Sources/Gateway/GatewayOnboardingView.swift:345-346` - Fixed `.system(size: 12)` monospaced text
|
||||
|
||||
**Recommended Fix:** Use semantic font styles (`.body`, `.headline`, etc.) instead of fixed sizes where possible. For custom dimensions, use `@ScaledMetric`:
|
||||
```swift
|
||||
@ScaledMetric(relativeTo: .body) private var orbSize: CGFloat = 190
|
||||
@ScaledMetric(relativeTo: .caption) private var dotSize: CGFloat = 9
|
||||
```
|
||||
|
||||
### H-2: OnboardingWizardView missing accessibility labels on interactive elements
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
**Description:** Multiple interactive elements lack accessibility labels:
|
||||
- `OnboardingModeRow` (line 861-884): Radio-style selection buttons have no `accessibilityAddTraits(.isButton)` or clear selection state announcement. VoiceOver users cannot tell which mode is selected.
|
||||
- Gateway list connect buttons (line 453-465): `ProgressView` and "Resolving..." text lack accessibility context.
|
||||
- QR scanner action (line 319-326): "Scan QR Code" button label is good, but the status line below it (line 340-345) is not connected as an accessibility value.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// OnboardingModeRow:
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
|
||||
.accessibilityLabel("\(self.title), \(self.subtitle)")
|
||||
.accessibilityValue(self.selected ? "Selected" : "Not selected")
|
||||
```
|
||||
|
||||
### H-3: No localization infrastructure
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** The entire app uses hardcoded English strings with no localization wrapping. No `NSLocalizedString`, `String(localized:)`, `.strings`/`.stringsdict` files, or `LocalizedStringKey` usage was found. This makes the app inaccessible to non-English speakers and violates Apple's HIG recommendation to support multiple languages.
|
||||
|
||||
**Key examples:**
|
||||
- `Sources/Settings/SettingsTab.swift`: All section headers, labels, help text
|
||||
- `Sources/Onboarding/OnboardingWizardView.swift`: "Welcome", "Connected", all step descriptions
|
||||
- `Sources/Status/StatusPill.swift`: "Connected", "Connecting...", "Error", "Offline"
|
||||
- `Sources/Voice/VoiceTab.swift`: All list labels
|
||||
|
||||
**Recommended Fix:** Wrap all user-facing strings in `String(localized:)` or use `LocalizedStringResource`. Create a `Localizable.xcstrings` catalog.
|
||||
|
||||
### H-4: No haptic feedback anywhere in the app
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** No `UIImpactFeedbackGenerator`, `UINotificationFeedbackGenerator`, `UISelectionFeedbackGenerator`, or `.sensoryFeedback()` modifier usage found. Key interaction points that would benefit from haptics:
|
||||
- Gateway connection success/failure
|
||||
- Voice wake trigger detection
|
||||
- Talk mode orb tap
|
||||
- QR code successfully scanned
|
||||
- Toggle state changes in Settings
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// iOS 17+ SwiftUI modifier:
|
||||
.sensoryFeedback(.success, trigger: appModel.gatewayServerName != nil)
|
||||
|
||||
// For Talk orb tap:
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: tapCount)
|
||||
```
|
||||
|
||||
### H-5: GatewayTrustPromptAlert uses deprecated `Alert` API
|
||||
|
||||
**File:** `Sources/Gateway/GatewayTrustPromptAlert.swift:17-35`
|
||||
**Description:** Uses the deprecated `Alert(title:message:primaryButton:secondaryButton:)` initializer pattern. This API was deprecated in iOS 15 in favor of the `alert(_:isPresented:actions:message:)` modifier. Same issue in `DeepLinkAgentPromptAlert.swift:15-33`.
|
||||
|
||||
**Recommended Fix:** Migrate to the modern `alert` modifier with `@ViewBuilder` actions.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: SettingsTab is a monolithic view (~650+ LOC)
|
||||
|
||||
**File:** `Sources/Settings/SettingsTab.swift`
|
||||
**Description:** SettingsTab contains the entire settings UI, including gateway connection, device features, advanced debug options, agent picker, and reset logic. The file has a `// swiftlint:disable type_body_length` comment acknowledging this. This makes the view hard to maintain and test.
|
||||
|
||||
**Recommended Fix:** Extract into focused sub-views:
|
||||
- `GatewaySettingsSection`
|
||||
- `DeviceFeaturesSection`
|
||||
- `AdvancedSettingsSection`
|
||||
- `DeviceInfoSection`
|
||||
|
||||
### M-2: No empty states for VoiceTab when disconnected
|
||||
|
||||
**File:** `Sources/Voice/VoiceTab.swift`
|
||||
**Description:** VoiceTab always shows the same status labels regardless of gateway connection state. When disconnected, it should show a clear empty state explaining that voice features require a gateway connection, with a CTA to connect.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
if appModel.gatewayServerName == nil {
|
||||
ContentUnavailableView(
|
||||
"Not Connected",
|
||||
systemImage: "antenna.radiowaves.left.and.right.slash",
|
||||
description: Text("Connect to a gateway to use voice features."))
|
||||
}
|
||||
```
|
||||
|
||||
### M-3: No loading/error states in GatewayQuickSetupSheet
|
||||
|
||||
**File:** `Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
**Description:** When `bestCandidate` is nil and no gateways are found, the sheet shows a text message but no visual indicator that discovery is actively running. No retry button or activity indicator is shown during the discovery phase.
|
||||
|
||||
### M-4: OverlayButton touch target may be too small
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:348-403`
|
||||
**Description:** `OverlayButton` uses `padding(10)` around a 16pt icon, resulting in a ~36pt touch target. Apple HIG recommends a minimum of 44pt x 44pt for touch targets.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
// or increase padding to at least 14pt
|
||||
```
|
||||
|
||||
### M-5: No keyboard shortcut support
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.keyboardShortcut()` modifiers found anywhere. iPad users with external keyboards have no keyboard navigation shortcuts for common actions like opening chat, settings, or toggling voice.
|
||||
|
||||
### M-6: TalkOrbOverlay hardcoded dimensions break on small screens
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:16,23,39`
|
||||
**Description:** The pulse rings are hardcoded at 320pt width/height, and the inner orb at 190pt. On iPhone SE (320pt logical width), the rings will extend beyond screen bounds. On iPad, the orb will appear relatively small.
|
||||
|
||||
**Recommended Fix:** Use `GeometryReader` or `@ScaledMetric` for adaptive sizing:
|
||||
```swift
|
||||
GeometryReader { proxy in
|
||||
let size = min(proxy.size.width, proxy.size.height) * 0.65
|
||||
Circle().frame(width: size, height: size)
|
||||
}
|
||||
```
|
||||
|
||||
### M-7: ScreenTab error overlay not accessible
|
||||
|
||||
**File:** `Sources/Screen/ScreenTab.swift:12-21`
|
||||
**Description:** The error text overlay appears only when `errorText` is set and the gateway is disconnected, but there is no VoiceOver announcement when the error appears or disappears. Screen reader users may not notice the error.
|
||||
|
||||
### M-8: No pull-to-refresh on any list view
|
||||
|
||||
**Files:** `Sources/Voice/VoiceTab.swift`, `Sources/Gateway/GatewayDiscoveryDebugLogView.swift`
|
||||
**Description:** List views do not support `.refreshable {}` for pull-to-refresh, which is a standard iOS interaction pattern.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: Inconsistent glass card styling between RootTabs and RootCanvas
|
||||
|
||||
**Files:** `Sources/RootTabs.swift`, `Sources/RootCanvas.swift`
|
||||
**Description:** `RootTabs` shows `StatusPill` without the `brighten` parameter (defaults to false), while `RootCanvas.CanvasContent` passes `brighten` based on color scheme. This can cause visual inconsistency if both code paths are reachable.
|
||||
|
||||
### L-2: VoiceWakeToast hardcoded top offset
|
||||
|
||||
**Files:** `Sources/RootTabs.swift:47`, `Sources/RootCanvas.swift:329`
|
||||
**Description:** `.safeAreaPadding(.top, 58)` is a magic number that assumes the StatusPill height. If the pill height changes (e.g., with Dynamic Type), the toast will overlap.
|
||||
|
||||
### L-3: No app-wide tint/accent color configuration
|
||||
|
||||
**Files:** `Sources/OpenClawApp.swift`
|
||||
**Description:** No `.tint()` or `accentColor` is set at the app level. The default blue accent is used for buttons and toggles, but the app uses `appModel.seamColor` for some elements. This creates visual inconsistency.
|
||||
|
||||
### L-4: ConnectionStatusBox uses hardcoded monospaced font size
|
||||
|
||||
**File:** `Sources/Onboarding/GatewayOnboardingView.swift:345-346`
|
||||
**Description:** `.font(.system(size: 12, weight: .regular, design: .monospaced))` will not scale with Dynamic Type.
|
||||
|
||||
### L-5: DateFormatter instances in GatewayDiscoveryDebugLogView are not locale-aware
|
||||
|
||||
**File:** `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:49-53`
|
||||
**Description:** `DateFormatter` with hardcoded `dateFormat = "HH:mm:ss"` does not respect the user's locale for time formatting. Should use `.dateStyle`/`.timeStyle` or `formatted()`.
|
||||
|
||||
### L-6: No transition animations on sheet presentations
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:92-111`
|
||||
**Description:** The `.sheet(item:)` presentations for settings, chat, and quick setup use default sheet transitions. Custom `presentationDetents` could improve the UX for smaller sheets like Quick Setup.
|
||||
|
||||
### L-7: Onboarding wizard duplicate padding
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift:344-346`
|
||||
**Description:** The welcome step has duplicate `.padding(.horizontal, 24)` on the status line (lines 344 and 345), which doubles the intended padding.
|
||||
|
||||
### L-8: No VoiceOver rotor actions
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.accessibilityAction(named:)` or custom rotor items are defined. Power VoiceOver users could benefit from custom actions for common operations.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Compliance Checklist
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|---|---|---|
|
||||
| VoiceOver labels on all interactive elements | Partial | Overlay buttons, StatusPill, ChatSheet close, SettingsTab close have labels. OnboardingModeRow, gateway list items, many settings toggles missing. |
|
||||
| VoiceOver hints for non-obvious actions | Partial | StatusPill has hint. Most buttons lack hints. |
|
||||
| VoiceOver value for stateful elements | Partial | StatusPill has value. Toggle states auto-announced by SwiftUI. OnboardingModeRow selection not announced. |
|
||||
| Dynamic Type support | Missing | No `@ScaledMetric`, no `dynamicTypeSize` environment, fixed font sizes throughout. |
|
||||
| Reduce Motion respected | Partial | RootTabs and StatusPill respect it. RootCanvas, TalkOrbOverlay, CameraFlashOverlay do not. |
|
||||
| Increased Contrast support | Partial | StatusGlassCard adjusts border for increased contrast. Other views do not check. |
|
||||
| Color not sole indicator | Pass | Status uses both color dots and text labels. |
|
||||
| Minimum touch target 44pt | Partial | Standard buttons OK. OverlayButton (~36pt) and StatusPill dot are undersized. |
|
||||
| Keyboard navigation (iPad) | Missing | No keyboard shortcuts defined. |
|
||||
| Localization readiness | Missing | All strings hardcoded in English. |
|
||||
| Haptic feedback | Missing | No haptic feedback in any interaction. |
|
||||
| iPad layout adaptation | Missing | No `horizontalSizeClass` or iPad-specific layouts. |
|
||||
| Dark mode support | Pass | Uses semantic colors, materials, and `.preferredColorScheme(.dark)` for canvas. |
|
||||
| Safe area handling | Pass | Correct use of `.ignoresSafeArea()` for screen, `.safeAreaPadding()` for overlays. |
|
||||
| Error state announcements | Missing | No `AccessibilityNotification.Announcement` for state changes. |
|
||||
| Focus management | Partial | `@FocusState` used in VoiceWakeWordsSettingsView. No focus management in onboarding. |
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Key Themes |
|
||||
|---|---|---|
|
||||
| Critical | 3 | Reduce Motion violations, flash accessibility |
|
||||
| High | 5 | Dynamic Type, localization, haptics, deprecated APIs, missing labels |
|
||||
| Medium | 8 | Monolithic views, empty states, touch targets, iPad, hardcoded sizes |
|
||||
| Low | 8 | Styling consistency, magic numbers, locale formatting, polish |
|
||||
@@ -134,10 +134,10 @@ extension OnboardingView {
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Refresh") {
|
||||
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
|
||||
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.help("Retry Tailscale discovery (DNS-SD).")
|
||||
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel {
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private var tailscaleServeFallbackTask: Task<Void, Never>?
|
||||
private var tailscaleServeFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
|
||||
|
||||
public init(
|
||||
@@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
self.scheduleTailscaleServeFallback()
|
||||
}
|
||||
|
||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
@@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
@@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel {
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.tailscaleServeFallbackTask?.cancel()
|
||||
self.tailscaleServeFallbackTask = nil
|
||||
self.tailscaleServeFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
@@ -168,22 +191,45 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func mapTailscaleServeBeacons(
|
||||
_ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway]
|
||||
{
|
||||
beacons.map { beacon in
|
||||
let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: nil,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
serviceHost: beacon.host,
|
||||
servicePort: beacon.port,
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: 22,
|
||||
gatewayPort: beacon.port,
|
||||
cliPath: nil,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
||||
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
||||
if !primaryFiltered.isEmpty {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
|
||||
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||
// and cross-network setups may rely on Tailscale Serve without DNS-SD.
|
||||
let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways
|
||||
guard !fallback.isEmpty else {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
||||
let combined = self.sortedDeduped(gateways: primary + fallback)
|
||||
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||
}
|
||||
|
||||
@@ -284,6 +330,39 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTailscaleServeFallback() {
|
||||
if Self.isRunningTests { return }
|
||||
guard self.tailscaleServeFallbackTask == nil else { return }
|
||||
self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||
let hasResults = await MainActor.run {
|
||||
if self.filterLocalGateways {
|
||||
return !self.gateways.isEmpty
|
||||
}
|
||||
return self.gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
|
||||
if !beacons.isEmpty {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8))
|
||||
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
@@ -291,11 +370,25 @@ public final class GatewayDiscoveryModel {
|
||||
return gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
|
||||
static func dedupeKey(for gateway: DiscoveredGateway) -> String {
|
||||
if let host = gateway.serviceHost?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
!host.isEmpty,
|
||||
let port = gateway.servicePort,
|
||||
port > 0
|
||||
{
|
||||
return "endpoint|\(host):\(port)"
|
||||
}
|
||||
return "stable|\(gateway.stableID)"
|
||||
}
|
||||
|
||||
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
||||
var seen = Set<String>()
|
||||
let deduped = gateways.filter { gateway in
|
||||
if seen.contains(gateway.stableID) { return false }
|
||||
seen.insert(gateway.stableID)
|
||||
let key = Self.dedupeKey(for: gateway)
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
return deduped.sorted {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TailscaleServeGatewayBeacon: Sendable, Equatable {
|
||||
var displayName: String
|
||||
var tailnetDns: String
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
|
||||
enum TailscaleServeGatewayDiscovery {
|
||||
private static let maxCandidates = 32
|
||||
private static let probeConcurrency = 6
|
||||
private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6
|
||||
|
||||
struct DiscoveryContext: Sendable {
|
||||
var tailscaleStatus: @Sendable () async -> String?
|
||||
var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool
|
||||
|
||||
static let live = DiscoveryContext(
|
||||
tailscaleStatus: { await readTailscaleStatus() },
|
||||
probeHost: { host, timeout in
|
||||
await probeHostForGatewayChallenge(host: host, timeout: timeout)
|
||||
})
|
||||
}
|
||||
|
||||
static func discover(
|
||||
timeoutSeconds: TimeInterval = 3.0,
|
||||
context: DiscoveryContext = .live) async -> [TailscaleServeGatewayBeacon]
|
||||
{
|
||||
guard timeoutSeconds > 0 else { return [] }
|
||||
guard let statusJson = await context.tailscaleStatus(),
|
||||
let status = parseStatus(statusJson)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let candidates = self.collectCandidates(status: status)
|
||||
if candidates.isEmpty { return [] }
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
let perProbeTimeout = min(self.defaultProbeTimeoutSeconds, max(0.5, timeoutSeconds * 0.45))
|
||||
|
||||
var byHost: [String: TailscaleServeGatewayBeacon] = [:]
|
||||
await withTaskGroup(of: TailscaleServeGatewayBeacon?.self) { group in
|
||||
var index = 0
|
||||
let workerCount = min(self.probeConcurrency, candidates.count)
|
||||
|
||||
func submitOne() {
|
||||
guard index < candidates.count else { return }
|
||||
let candidate = candidates[index]
|
||||
index += 1
|
||||
group.addTask {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
let timeout = min(perProbeTimeout, remaining)
|
||||
let reachable = await context.probeHost(candidate.dnsName, timeout)
|
||||
if !reachable {
|
||||
return nil
|
||||
}
|
||||
return TailscaleServeGatewayBeacon(
|
||||
displayName: candidate.displayName,
|
||||
tailnetDns: candidate.dnsName,
|
||||
host: candidate.dnsName,
|
||||
port: 443)
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..<workerCount {
|
||||
submitOne()
|
||||
}
|
||||
|
||||
while let beacon = await group.next() {
|
||||
if let beacon {
|
||||
byHost[beacon.host.lowercased()] = beacon
|
||||
}
|
||||
submitOne()
|
||||
}
|
||||
}
|
||||
|
||||
return byHost.values.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private struct Candidate: Sendable {
|
||||
var dnsName: String
|
||||
var displayName: String
|
||||
}
|
||||
|
||||
private static func collectCandidates(status: TailscaleStatus) -> [Candidate] {
|
||||
let selfDns = normalizeDnsName(status.selfNode?.dnsName)
|
||||
var out: [Candidate] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
for node in status.peer.values {
|
||||
if node.online == false {
|
||||
continue
|
||||
}
|
||||
guard let dnsName = normalizeDnsName(node.dnsName) else {
|
||||
continue
|
||||
}
|
||||
if dnsName == selfDns {
|
||||
continue
|
||||
}
|
||||
if seen.contains(dnsName) {
|
||||
continue
|
||||
}
|
||||
seen.insert(dnsName)
|
||||
|
||||
out.append(Candidate(
|
||||
dnsName: dnsName,
|
||||
displayName: displayName(hostName: node.hostName, dnsName: dnsName)))
|
||||
|
||||
if out.count >= self.maxCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private static func displayName(hostName: String?, dnsName: String) -> String {
|
||||
if let hostName {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return dnsName
|
||||
.split(separator: ".")
|
||||
.first
|
||||
.map(String.init) ?? dnsName
|
||||
}
|
||||
|
||||
private static func normalizeDnsName(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let withoutDot = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
let lower = withoutDot.lowercased()
|
||||
return lower.isEmpty ? nil : lower
|
||||
}
|
||||
|
||||
private static func readTailscaleStatus() async -> String? {
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"tailscale",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
guard let executable = self.resolveExecutablePath(candidate) else { continue }
|
||||
if let stdout = await self.run(path: executable, args: ["status", "--json"], timeout: 1.0) {
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func resolveExecutablePath(
|
||||
_ candidate: String,
|
||||
env: [String: String] = ProcessInfo.processInfo.environment) -> String?
|
||||
{
|
||||
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let hasPathSeparator = trimmed.contains("/")
|
||||
if hasPathSeparator {
|
||||
return fileManager.isExecutableFile(atPath: trimmed) ? trimmed : nil
|
||||
}
|
||||
|
||||
let pathRaw = env["PATH"] ?? ""
|
||||
let entries = pathRaw.split(separator: ":").map(String.init)
|
||||
for entry in entries {
|
||||
let dir = entry.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if dir.isEmpty { continue }
|
||||
let fullPath = URL(fileURLWithPath: dir)
|
||||
.appendingPathComponent(trimmed)
|
||||
.path
|
||||
if fileManager.isExecutableFile(atPath: fullPath) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func run(path: String, args: [String], timeout: TimeInterval) async -> String? {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
continuation.resume(returning: self.runBlocking(path: path, args: args, timeout: timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runBlocking(path: String, args: [String], timeout: TimeInterval) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning, Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return output?.isEmpty == false ? output : nil
|
||||
}
|
||||
|
||||
private static func parseStatus(_ raw: String) -> TailscaleStatus? {
|
||||
guard let data = raw.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(TailscaleStatus.self, from: data)
|
||||
}
|
||||
|
||||
private static func probeHostForGatewayChallenge(host: String, timeout: TimeInterval) async -> Bool {
|
||||
var components = URLComponents()
|
||||
components.scheme = "wss"
|
||||
components.host = host
|
||||
guard let url = components.url else { return false }
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = max(0.5, timeout)
|
||||
config.timeoutIntervalForResource = max(0.5, timeout)
|
||||
let session = URLSession(configuration: config)
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.resume()
|
||||
|
||||
defer {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: timeout,
|
||||
onTimeout: { NSError(domain: "TailscaleServeDiscovery", code: 1, userInfo: nil) },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
if isConnectChallenge(message: message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isConnectChallenge(message: URLSessionWebSocketTask.Message) -> Bool {
|
||||
let data: Data
|
||||
switch message {
|
||||
case let .data(value):
|
||||
data = value
|
||||
case let .string(value):
|
||||
guard let encoded = value.data(using: .utf8) else { return false }
|
||||
data = encoded
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let dict = object as? [String: Any],
|
||||
let type = dict["type"] as? String,
|
||||
type == "event",
|
||||
let event = dict["event"] as? String
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return event == "connect.challenge"
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatus: Decodable {
|
||||
struct Node: Decodable {
|
||||
let dnsName: String?
|
||||
let hostName: String?
|
||||
let online: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case dnsName = "DNSName"
|
||||
case hostName = "HostName"
|
||||
case online = "Online"
|
||||
}
|
||||
}
|
||||
|
||||
let selfNode: Node?
|
||||
let peer: [String: Node]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfNode = "Self"
|
||||
case peer = "Peer"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenClawDiscovery
|
||||
@testable import OpenClawDiscovery
|
||||
import Testing
|
||||
|
||||
@Suite
|
||||
@@ -121,4 +121,50 @@ struct GatewayDiscoveryModelTests {
|
||||
host: "studio.local",
|
||||
port: 2201) == "peter@studio.local:2201")
|
||||
}
|
||||
|
||||
@Test func dedupeKeyPrefersResolvedEndpointAcrossSources() {
|
||||
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host",
|
||||
debugID: "wide-area",
|
||||
isLocal: false)
|
||||
let serve = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve))
|
||||
}
|
||||
|
||||
@Test func dedupeKeyFallsBackToStableIDWithoutEndpoint() {
|
||||
let unresolved = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
@Suite
|
||||
struct TailscaleServeGatewayDiscoveryTests {
|
||||
@Test func discoversServeGatewayFromTailnetPeers() async {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
},
|
||||
"Peer": {
|
||||
"peer-1": {
|
||||
"DNSName": "gateway-host.tailnet-example.ts.net.",
|
||||
"HostName": "gateway-host",
|
||||
"Online": true
|
||||
},
|
||||
"peer-2": {
|
||||
"DNSName": "offline.tailnet-example.ts.net.",
|
||||
"HostName": "offline-box",
|
||||
"Online": false
|
||||
},
|
||||
"peer-3": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
probeHost: { host, _ in
|
||||
host == "gateway-host.tailnet-example.ts.net"
|
||||
})
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.count == 1)
|
||||
#expect(beacons.first?.displayName == "gateway-host")
|
||||
#expect(beacons.first?.tailnetDns == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.host == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.port == 443)
|
||||
}
|
||||
|
||||
@Test func returnsEmptyWhenStatusUnavailable() async {
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { nil },
|
||||
probeHost: { _, _ in true })
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolvesBareExecutableFromPATH() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let executable = tempDir.appendingPathComponent("tailscale")
|
||||
try "#!/bin/sh\necho ok\n".write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let env: [String: String] = ["PATH": tempDir.path]
|
||||
let resolved = TailscaleServeGatewayDiscovery.resolveExecutablePath("tailscale", env: env)
|
||||
#expect(resolved == executable.path)
|
||||
}
|
||||
|
||||
@Test func rejectsMissingExecutableCandidate() {
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil)
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ extension URLSession: WebSocketSessioning {
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.webSocketTask(with: url)
|
||||
// Avoid "Message too long" receive errors for large snapshots / history payloads.
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
task.maximumMessageSize = 4 * 1024 * 1024 // 4 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,70 @@ public struct GatewayTLSParams: Sendable {
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let suiteName = "ai.openclaw.shared"
|
||||
private static let keyPrefix = "gateway.tls."
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
// Legacy UserDefaults location used before Keychain migration.
|
||||
private static let legacySuiteName = "ai.openclaw.shared"
|
||||
private static let legacyKeyPrefix = "gateway.tls."
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
|
||||
let raw = self.keychainLoad(account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw?.isEmpty == false { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
self.keychainSave(value, account: stableID)
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
/// On first Keychain read for a given stableID, move any legacy UserDefaults
|
||||
/// fingerprint into Keychain and remove the old entry.
|
||||
private static func migrateFromUserDefaultsIfNeeded(stableID: String) {
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
|
||||
let legacyKey = self.legacyKeyPrefix + stableID
|
||||
guard let existing = defaults.string(forKey: legacyKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
else { return }
|
||||
if self.keychainLoad(account: stableID) == nil {
|
||||
guard self.keychainSave(existing, account: stableID) else { return }
|
||||
}
|
||||
defaults.removeObject(forKey: legacyKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-contained Keychain helpers (OpenClawKit can't import iOS KeychainStore)
|
||||
|
||||
private static func keychainLoad(account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func keychainSave(_ value: String, account: String) -> Bool {
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Delete-then-add to enforce accessibility attribute.
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +99,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
task.maximumMessageSize = 4 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ OpenClaw uses Brave Search as the default provider for `web_search`.
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||
- Brave provides paid plans; check the Brave API portal for current limits.
|
||||
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -17,6 +17,7 @@ host configuration.
|
||||
- **AccountId**: per‑channel account instance (when supported).
|
||||
- Optional channel default account: `channels.<channel>.defaultAccount` chooses
|
||||
which account is used when an outbound path does not specify `accountId`.
|
||||
- In multi-account setups, set an explicit default (`defaultAccount` or `accounts.default`) when two or more accounts are configured. Without it, fallback routing may pick the first normalized account ID.
|
||||
- **AgentId**: an isolated workspace + session store (“brain”).
|
||||
- **SessionKey**: the bucket key used to store context and control concurrency.
|
||||
|
||||
|
||||
@@ -55,6 +55,45 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
## Native slash commands
|
||||
|
||||
Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via
|
||||
the Mattermost API and receives callback POSTs on the gateway HTTP server.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
native: true,
|
||||
nativeSkills: true,
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
// Use when Mattermost cannot reach the gateway directly (reverse proxy/public URL).
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable.
|
||||
- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`.
|
||||
- For multi-account setups, `commands` can be set at the top level or under
|
||||
`channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
|
||||
- Command callbacks are validated with per-command tokens and fail closed when token checks fail.
|
||||
- Reachability requirement: the callback endpoint must be reachable from the Mattermost server.
|
||||
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw.
|
||||
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw.
|
||||
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`.
|
||||
- Mattermost egress allowlist requirement:
|
||||
- If your callback targets private/tailnet/internal addresses, set Mattermost
|
||||
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
|
||||
- Use host/domain entries, not full URLs.
|
||||
- Good: `gateway.tailnet-name.ts.net`
|
||||
- Bad: `https://gateway.tailnet-name.ts.net`
|
||||
|
||||
## Environment variables (default account)
|
||||
|
||||
Set these on the gateway host if you prefer env vars:
|
||||
|
||||
@@ -739,6 +739,8 @@ Primary reference:
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
- Multi-account precedence:
|
||||
- When two or more account IDs are configured, set `channels.telegram.defaultAccount` (or include `channels.telegram.accounts.default`) to make default routing explicit.
|
||||
- If neither is set, OpenClaw falls back to the first normalized account ID and `openclaw doctor` warns.
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
- Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`.
|
||||
|
||||
@@ -205,6 +205,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
|
||||
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
|
||||
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
|
||||
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
|
||||
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
|
||||
- Retry policy: see [Retry policy](/concepts/retry).
|
||||
@@ -443,6 +444,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
commands: {
|
||||
native: true, // opt-in
|
||||
nativeSkills: true,
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
// Optional explicit URL for reverse-proxy/public deployments
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
},
|
||||
@@ -452,6 +460,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
|
||||
Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix).
|
||||
|
||||
When Mattermost native commands are enabled:
|
||||
|
||||
- `commands.callbackPath` must be a path (for example `/api/channels/mattermost/command`), not a full URL.
|
||||
- `commands.callbackUrl` must resolve to the OpenClaw gateway endpoint and be reachable from the Mattermost server.
|
||||
- For private/tailnet/internal callback hosts, Mattermost may require
|
||||
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
|
||||
Use host/domain values, not full URLs.
|
||||
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
|
||||
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
|
||||
- Optional `channels.mattermost.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
|
||||
@@ -128,6 +128,11 @@ Current migrations:
|
||||
→ `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
|
||||
- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork`
|
||||
|
||||
Doctor warnings also include account-default guidance for multi-account channels:
|
||||
|
||||
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
|
||||
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
|
||||
|
||||
### 2b) OpenCode Zen provider overrides
|
||||
|
||||
If you’ve added `models.providers.opencode` (or `opencode-zen`) manually, it
|
||||
|
||||
@@ -82,12 +82,6 @@ See [Memory](/concepts/memory).
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
**Brave free tier (generous):**
|
||||
|
||||
- **2,000 requests/month**
|
||||
- **1 request/second**
|
||||
- **Credit card required** for verification (no charge unless you upgrade)
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
### 5) Web fetch tool (Firecrawl)
|
||||
|
||||
@@ -10,7 +10,7 @@ read_when:
|
||||
|
||||
# Diffs
|
||||
|
||||
`diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
|
||||
`diffs` is an optional plugin tool and companion skill that turns change content into a read-only diff artifact for agents.
|
||||
|
||||
It accepts either:
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- |
|
||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------------- | -------------------------------------------- |
|
||||
| **Brave** (default) | Fast, structured results | Traditional search results; AI-use terms apply | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||
|
||||
@@ -94,9 +94,13 @@ Example: switch to Perplexity Sonar (direct API):
|
||||
2. In the dashboard, choose the **Data for Search** plan (not “Data for AI”) and generate an API key.
|
||||
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides a free tier plus paid plans; check the Brave API portal for the
|
||||
Brave provides paid plans; check the Brave API portal for the
|
||||
current limits and pricing.
|
||||
|
||||
Brave Terms include restrictions on some AI-related uses of Search Results.
|
||||
Review the Brave Terms of Service and confirm your intended use is compliant.
|
||||
For legal questions, consult your counsel.
|
||||
|
||||
### Where to set the key (recommended)
|
||||
|
||||
**Recommended:** run `openclaw configure --section web`. It stores the key in
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -16,6 +16,8 @@ The tool can return:
|
||||
- `details.filePath`: a local rendered artifact path when file rendering is requested
|
||||
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
|
||||
|
||||
When the plugin is enabled, it also ships a companion skill from `skills/` that guides when to use `diffs`. This guidance is delivered through normal skill loading, not unconditional prompt-hook injection on every turn.
|
||||
|
||||
This means an agent can:
|
||||
|
||||
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and prompt guidance hook", () => {
|
||||
it("registers the tool and http route", () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
@@ -43,8 +43,7 @@ describe("diffs plugin registration", () => {
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
expect(on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./src/config.js";
|
||||
import { createDiffsHttpHandler } from "./src/http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./src/store.js";
|
||||
import { createDiffsTool } from "./src/tool.js";
|
||||
|
||||
@@ -35,9 +34,6 @@ const plugin = {
|
||||
allowRemoteViewer: security.allowRemoteViewer,
|
||||
}),
|
||||
});
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "diffs",
|
||||
"name": "Diffs",
|
||||
"description": "Read-only diff viewer and file renderer for agents.",
|
||||
"skills": ["./skills"],
|
||||
"uiHints": {
|
||||
"defaults.fontFamily": {
|
||||
"label": "Default Font",
|
||||
|
||||
22
extensions/diffs/skills/diffs/SKILL.md
Normal file
22
extensions/diffs/skills/diffs/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: diffs
|
||||
description: Use the diffs tool to produce real, shareable diffs (viewer URL, file artifact, or both) instead of manual edit summaries.
|
||||
---
|
||||
|
||||
When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.
|
||||
|
||||
The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.
|
||||
|
||||
Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.
|
||||
|
||||
Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.
|
||||
|
||||
For large or high-fidelity files, use `fileQuality` (`standard`|`hq`|`print`) and optionally override `fileScale`/`fileMaxWidth`.
|
||||
|
||||
When you need to deliver the rendered file to a user or channel, do not rely on the raw tool-result renderer. Instead, call the `message` tool and pass `details.filePath` through `path` or `filePath`.
|
||||
|
||||
Use `mode=both` when you want both the gateway viewer URL and the rendered artifact.
|
||||
|
||||
If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.
|
||||
|
||||
Include `path` for before/after text when you know the file name.
|
||||
@@ -1,11 +0,0 @@
|
||||
export const DIFFS_AGENT_GUIDANCE = [
|
||||
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
|
||||
"The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.",
|
||||
"Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.",
|
||||
"Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.",
|
||||
"For large or high-fidelity files, use `fileQuality` (`standard`|`hq`|`print`) and optionally override `fileScale`/`fileMaxWidth`.",
|
||||
"When you need to deliver the rendered file to a user or channel, do not rely on the raw tool-result renderer. Instead, call the `message` tool and pass `details.filePath` through `path` or `filePath`.",
|
||||
"Use `mode=both` when you want both the gateway viewer URL and the rendered artifact.",
|
||||
"If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.",
|
||||
"Include `path` for before/after text when you know the file name.",
|
||||
].join("\n");
|
||||
@@ -1,38 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
|
||||
import { parseFeishuMessageEvent } from "./bot.js";
|
||||
|
||||
type Mentions = FeishuMessageEvent["message"]["mentions"];
|
||||
function makeEvent(
|
||||
text: string,
|
||||
mentions?: Array<{ key: string; name: string; id: { open_id?: string; user_id?: string } }>,
|
||||
chatType: "p2p" | "group" = "p2p",
|
||||
) {
|
||||
return {
|
||||
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
|
||||
message: {
|
||||
message_id: "msg_1",
|
||||
chat_id: "oc_chat1",
|
||||
chat_type: chatType,
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
mentions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripBotMention", () => {
|
||||
const BOT_OPEN_ID = "ou_bot";
|
||||
|
||||
describe("normalizeMentions (via parseFeishuMessageEvent)", () => {
|
||||
it("returns original text when mentions are missing", () => {
|
||||
expect(stripBotMention("hello world", undefined)).toBe("hello world");
|
||||
const ctx = parseFeishuMessageEvent(makeEvent("hello world", undefined) as any, BOT_OPEN_ID);
|
||||
expect(ctx.content).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips mention name and key for normal mentions", () => {
|
||||
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
||||
expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
|
||||
it("strips bot mention in p2p (addressing prefix, not semantic content)", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_bot_1 hello", [
|
||||
{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe("hello");
|
||||
});
|
||||
|
||||
it("treats mention.name regex metacharacters as literal text", () => {
|
||||
const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
|
||||
expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
|
||||
it("normalizes bot mention to <at> tag in group (semantic content)", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent(
|
||||
"@_bot_1 hello",
|
||||
[{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }],
|
||||
"group",
|
||||
) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe('<at user_id="ou_bot">Bot</at> hello');
|
||||
});
|
||||
|
||||
it("treats mention.key regex metacharacters as literal text", () => {
|
||||
const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
|
||||
expect(stripBotMention("hello world", mentions)).toBe("hello world");
|
||||
it("strips bot mention but normalizes other mentions in p2p (mention-forward)", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_bot_1 @_user_alice hello", [
|
||||
{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } },
|
||||
{ key: "@_user_alice", name: "Alice", id: { open_id: "ou_alice" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe('<at user_id="ou_alice">Alice</at> hello');
|
||||
});
|
||||
|
||||
it("trims once after all mention replacements", () => {
|
||||
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
||||
expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
|
||||
it("falls back to @name when open_id is absent", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_user_1 hi", [
|
||||
{ key: "@_user_1", name: "Alice", id: { user_id: "uid_alice" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe("@Alice hi");
|
||||
});
|
||||
|
||||
it("strips multiple mentions in one pass", () => {
|
||||
const mentions: Mentions = [
|
||||
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
|
||||
{ key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
|
||||
];
|
||||
expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
|
||||
it("falls back to plain @name when no id is present", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_unknown hey", [{ key: "@_unknown", name: "Nobody", id: {} }]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe("@Nobody hey");
|
||||
});
|
||||
|
||||
it("treats mention key regex metacharacters as literal text", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("hello world", [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe("hello world");
|
||||
});
|
||||
|
||||
it("normalizes multiple mentions in one pass", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_bot_1 hi @_user_2", [
|
||||
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
|
||||
{ key: "@_user_2", name: "User Two", id: { open_id: "ou_user_2" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe(
|
||||
'<at user_id="ou_bot_1">Bot One</at> hi <at user_id="ou_user_2">User Two</at>',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats $ in display name as literal (no replacement-pattern interpolation)", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_user_1 hi", [
|
||||
{ key: "@_user_1", name: "$& the user", id: { open_id: "ou_x" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
// $ is preserved literally (no $& pattern substitution); & is not escaped in tag body
|
||||
expect(ctx.content).toBe('<at user_id="ou_x">$& the user</at> hi');
|
||||
});
|
||||
|
||||
it("escapes < and > in mention name to protect tag structure", () => {
|
||||
const ctx = parseFeishuMessageEvent(
|
||||
makeEvent("@_user_1 test", [
|
||||
{ key: "@_user_1", name: "<script>", id: { open_id: "ou_x" } },
|
||||
]) as any,
|
||||
BOT_OPEN_ID,
|
||||
);
|
||||
expect(ctx.content).toBe('<at user_id="ou_x"><script></at> test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,7 @@ import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
import {
|
||||
escapeRegExp,
|
||||
extractMentionTargets,
|
||||
extractMessageBody,
|
||||
isMentionForwardRequest,
|
||||
} from "./mention.js";
|
||||
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
||||
import {
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuReplyPolicy,
|
||||
@@ -455,7 +450,11 @@ function formatSubMessageContent(content: string, contentType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string, botName?: string): boolean {
|
||||
function checkBotMentioned(
|
||||
event: FeishuMessageEvent,
|
||||
botOpenId?: string,
|
||||
botName?: string,
|
||||
): boolean {
|
||||
if (!botOpenId) return false;
|
||||
// Check for @all (@_all in Feishu) — treat as mentioning every bot
|
||||
const rawContent = event.message.content ?? "";
|
||||
@@ -478,17 +477,30 @@ function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string, botNam
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripBotMention(
|
||||
function normalizeMentions(
|
||||
text: string,
|
||||
mentions?: FeishuMessageEvent["message"]["mentions"],
|
||||
botStripId?: string,
|
||||
): string {
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
|
||||
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapeName = (value: string) => value.replace(/</g, "<").replace(/>/g, ">");
|
||||
let result = text;
|
||||
|
||||
for (const mention of mentions) {
|
||||
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
|
||||
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
|
||||
const mentionId = mention.id.open_id;
|
||||
const replacement =
|
||||
botStripId && mentionId === botStripId
|
||||
? ""
|
||||
: mentionId
|
||||
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
|
||||
: `@${mention.name}`;
|
||||
|
||||
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
|
||||
}
|
||||
return result.trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -760,7 +772,15 @@ export function parseFeishuMessageEvent(
|
||||
): FeishuMessageContext {
|
||||
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
||||
const mentionedBot = checkBotMentioned(event, botOpenId, botName);
|
||||
const content = stripBotMention(rawContent, event.message.mentions);
|
||||
const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
|
||||
// In p2p, the bot mention is a pure addressing prefix with no semantic value;
|
||||
// strip it so slash commands like @Bot /help still have a leading /.
|
||||
// Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
|
||||
const content = normalizeMentions(
|
||||
rawContent,
|
||||
event.message.mentions,
|
||||
event.message.chat_type === "p2p" ? botOpenId : undefined,
|
||||
);
|
||||
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
||||
const senderUserId = event.sender.sender_id.user_id?.trim();
|
||||
const senderFallbackId = senderOpenId || senderUserId || "";
|
||||
@@ -774,6 +794,7 @@ export function parseFeishuMessageEvent(
|
||||
senderOpenId: senderFallbackId,
|
||||
chatType: event.message.chat_type,
|
||||
mentionedBot,
|
||||
hasAnyMention,
|
||||
rootId: event.message.root_id || undefined,
|
||||
parentId: event.message.parent_id || undefined,
|
||||
threadId: event.message.thread_id || undefined,
|
||||
@@ -786,9 +807,6 @@ export function parseFeishuMessageEvent(
|
||||
const mentionTargets = extractMentionTargets(event, botOpenId);
|
||||
if (mentionTargets.length > 0) {
|
||||
ctx.mentionTargets = mentionTargets;
|
||||
// Extract message body (remove all @ placeholders)
|
||||
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
|
||||
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,12 +816,13 @@ export function parseFeishuMessageEvent(
|
||||
export function buildFeishuAgentBody(params: {
|
||||
ctx: Pick<
|
||||
FeishuMessageContext,
|
||||
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
|
||||
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
|
||||
>;
|
||||
quotedContent?: string;
|
||||
permissionErrorForAgent?: PermissionError;
|
||||
botOpenId?: string;
|
||||
}): string {
|
||||
const { ctx, quotedContent, permissionErrorForAgent } = params;
|
||||
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
|
||||
let messageBody = ctx.content;
|
||||
if (quotedContent) {
|
||||
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
||||
@@ -813,6 +832,16 @@ export function buildFeishuAgentBody(params: {
|
||||
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
||||
messageBody = `${speaker}: ${messageBody}`;
|
||||
|
||||
if (ctx.hasAnyMention) {
|
||||
const botIdHint = botOpenId?.trim();
|
||||
messageBody +=
|
||||
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
|
||||
`Treat these as real mentions of Feishu entities (users or bots).]`;
|
||||
if (botIdHint) {
|
||||
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
||||
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
||||
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
||||
@@ -861,7 +890,7 @@ export async function handleFeishuMessage(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId, botName ?? account.config?.botName);
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
const isDirect = !isGroup;
|
||||
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
||||
@@ -1223,6 +1252,7 @@ export async function handleFeishuMessage(params: {
|
||||
ctx,
|
||||
quotedContent,
|
||||
permissionErrorForAgent,
|
||||
botOpenId,
|
||||
});
|
||||
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
||||
if (permissionErrorForAgent) {
|
||||
|
||||
@@ -88,6 +88,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
groups: {
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: {
|
||||
schema: {
|
||||
|
||||
@@ -1,49 +1,59 @@
|
||||
import type { PluginHookRunner } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_RESET_TRIGGERS } from "../../../config/sessions/types.js";
|
||||
const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"] as const;
|
||||
|
||||
type FeishuBeforeResetContext = {
|
||||
cfg: Record<string, unknown>;
|
||||
sessionEntry: Record<string, unknown>;
|
||||
previousSessionEntry?: Record<string, unknown>;
|
||||
commandSource: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type FeishuBeforeResetEvent = {
|
||||
type: "command";
|
||||
action: "new" | "reset";
|
||||
context: FeishuBeforeResetContext;
|
||||
};
|
||||
|
||||
type FeishuBeforeResetRunner = {
|
||||
runBeforeReset: (
|
||||
event: FeishuBeforeResetEvent,
|
||||
ctx: { agentId: string; sessionKey: string },
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Feishu command messages and trigger appropriate hooks
|
||||
* Handle Feishu command messages and trigger reset hooks.
|
||||
*/
|
||||
export async function handleFeishuCommand(
|
||||
messageText: string,
|
||||
sessionKey: string,
|
||||
hookRunner: PluginHookRunner,
|
||||
context: {
|
||||
cfg: any;
|
||||
sessionEntry: any;
|
||||
previousSessionEntry?: any;
|
||||
commandSource: string;
|
||||
timestamp: number;
|
||||
}
|
||||
hookRunner: FeishuBeforeResetRunner,
|
||||
context: FeishuBeforeResetContext,
|
||||
): Promise<boolean> {
|
||||
// Check if message is a reset command
|
||||
const trimmed = messageText.trim().toLowerCase();
|
||||
const isResetCommand = DEFAULT_RESET_TRIGGERS.some(trigger =>
|
||||
trimmed === trigger || trimmed.startsWith(`${trigger} `)
|
||||
const isResetCommand = DEFAULT_RESET_TRIGGERS.some(
|
||||
(trigger) => trimmed === trigger || trimmed.startsWith(`${trigger} `),
|
||||
);
|
||||
if (!isResetCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = trimmed.split(" ")[0];
|
||||
const action: "new" | "reset" = command === "/new" ? "new" : "reset";
|
||||
await hookRunner.runBeforeReset(
|
||||
{
|
||||
type: "command",
|
||||
action,
|
||||
context: {
|
||||
...context,
|
||||
commandSource: "feishu",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
},
|
||||
);
|
||||
|
||||
if (isResetCommand) {
|
||||
// Extract the actual command (without arguments)
|
||||
const command = trimmed.split(' ')[0];
|
||||
|
||||
// Trigger the before_reset hook
|
||||
await hookRunner.runBeforeReset(
|
||||
{
|
||||
type: "command",
|
||||
action: command.replace('/', '') as "new" | "reset",
|
||||
context: {
|
||||
...context,
|
||||
commandSource: "feishu"
|
||||
}
|
||||
},
|
||||
{
|
||||
agentId: "main", // or extract from sessionKey
|
||||
sessionKey
|
||||
}
|
||||
);
|
||||
|
||||
return true; // Command was handled
|
||||
}
|
||||
|
||||
return false; // Not a command we handle
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export type FeishuMessageContext = {
|
||||
senderName?: string;
|
||||
chatType: "p2p" | "group" | "private";
|
||||
mentionedBot: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
rootId?: string;
|
||||
parentId?: string;
|
||||
threadId?: string;
|
||||
@@ -52,8 +53,6 @@ export type FeishuMessageContext = {
|
||||
contentType: string;
|
||||
/** Mention forward targets (excluding the bot itself) */
|
||||
mentionTargets?: MentionTarget[];
|
||||
/** Extracted message body (after removing @ placeholders) */
|
||||
mentionMessageBody?: string;
|
||||
};
|
||||
|
||||
export type FeishuSendResult = {
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
"openclaw": ">=2026.3.1"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"ajv": "^8.18.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.48"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "0.55.3",
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
|
||||
"markdown-it": "14.1.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
import { getSlashCommandState, registerSlashCommandRoute } from "./src/mattermost/slash-state.js";
|
||||
import { setMattermostRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -11,6 +12,11 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setMattermostRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: mattermostPlugin });
|
||||
|
||||
// Register the HTTP route for slash command callbacks.
|
||||
// The actual command registration with MM happens in the monitor
|
||||
// after the bot connects and we know the team ID.
|
||||
registerSlashCommandRoute(api);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -172,6 +172,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
|
||||
@@ -8,6 +8,20 @@ import {
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const MattermostSlashCommandsSchema = z
|
||||
.object({
|
||||
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||
native: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: z.string().optional(),
|
||||
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||
callbackUrl: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -35,6 +49,7 @@ const MattermostAccountSchemaBase = z
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
commands: MattermostSlashCommandsSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -83,7 +83,21 @@ function mergeMattermostAccountConfig(
|
||||
defaultAccount?: unknown;
|
||||
};
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
|
||||
// Shallow merging is fine for most keys, but `commands` should be merged
|
||||
// so that account-specific overrides (callbackPath/callbackUrl) do not
|
||||
// accidentally reset global settings like `native: true`.
|
||||
const mergedCommands = {
|
||||
...(base.commands ?? {}),
|
||||
...(account.commands ?? {}),
|
||||
};
|
||||
|
||||
const merged = { ...base, ...account };
|
||||
if (Object.keys(mergedCommands).length > 0) {
|
||||
merged.commands = mergedCommands;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
|
||||
@@ -190,6 +190,19 @@ export async function createMattermostPost(
|
||||
});
|
||||
}
|
||||
|
||||
export type MattermostTeam = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchMattermostUserTeams(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostTeam[]> {
|
||||
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
listSkillCommandsForAgents,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUser,
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
@@ -54,6 +56,19 @@ import {
|
||||
} from "./monitor-websocket.js";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
cleanupSlashCommands,
|
||||
isSlashCommandsEnabled,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
import {
|
||||
activateSlashCommands,
|
||||
deactivateSlashCommands,
|
||||
getSlashCommandState,
|
||||
} from "./slash-state.js";
|
||||
|
||||
export type MonitorMattermostOpts = {
|
||||
botToken?: string;
|
||||
@@ -204,6 +219,144 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const botUsername = botUser.username?.trim() || undefined;
|
||||
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
||||
|
||||
// ─── Slash command registration ──────────────────────────────────────────
|
||||
const commandsRaw = account.config.commands as
|
||||
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
const slashConfig = resolveSlashCommandConfig(commandsRaw);
|
||||
const slashEnabled = isSlashCommandsEnabled(slashConfig);
|
||||
|
||||
if (slashEnabled) {
|
||||
try {
|
||||
const teams = await fetchMattermostUserTeams(client, botUserId);
|
||||
|
||||
// Use the *runtime* listener port when available (e.g. `openclaw gateway run --port <port>`).
|
||||
// The gateway sets OPENCLAW_GATEWAY_PORT when it boots, but the config file may still contain
|
||||
// a different port.
|
||||
const envPortRaw = process.env.OPENCLAW_GATEWAY_PORT?.trim();
|
||||
const envPort = envPortRaw ? Number.parseInt(envPortRaw, 10) : NaN;
|
||||
const gatewayPort =
|
||||
Number.isFinite(envPort) && envPort > 0 ? envPort : (cfg.gateway?.port ?? 18789);
|
||||
|
||||
const callbackUrl = resolveCallbackUrl({
|
||||
config: slashConfig,
|
||||
gatewayPort,
|
||||
gatewayHost: cfg.gateway?.customBindHost ?? undefined,
|
||||
});
|
||||
|
||||
const isLoopbackHost = (hostname: string) =>
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
|
||||
try {
|
||||
const mmHost = new URL(baseUrl).hostname;
|
||||
const callbackHost = new URL(callbackUrl).hostname;
|
||||
|
||||
// NOTE: We cannot infer network reachability from hostnames alone.
|
||||
// Mattermost might be accessed via a public domain while still running on the same
|
||||
// machine as the gateway (where http://localhost:<port> is valid).
|
||||
// So treat loopback callback URLs as an advisory warning only.
|
||||
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
||||
runtime.error?.(
|
||||
`mattermost: slash commands callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// URL parse failed; ignore and continue (we'll fail naturally if registration requests break).
|
||||
}
|
||||
|
||||
const commandsToRegister: import("./slash-commands.js").MattermostCommandSpec[] = [
|
||||
...DEFAULT_COMMAND_SPECS,
|
||||
];
|
||||
|
||||
if (slashConfig.nativeSkills === true) {
|
||||
try {
|
||||
const skillCommands = listSkillCommandsForAgents({ cfg: cfg as any });
|
||||
for (const spec of skillCommands) {
|
||||
const name = typeof spec.name === "string" ? spec.name.trim() : "";
|
||||
if (!name) continue;
|
||||
const trigger = name.startsWith("oc_") ? name : `oc_${name}`;
|
||||
commandsToRegister.push({
|
||||
trigger,
|
||||
description: spec.description || `Run skill ${name}`,
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: name,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`mattermost: failed to list skill commands: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by trigger
|
||||
const seen = new Set<string>();
|
||||
const dedupedCommands = commandsToRegister.filter((cmd) => {
|
||||
const key = cmd.trigger.trim();
|
||||
if (!key) return false;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
const allRegistered: import("./slash-commands.js").MattermostRegisteredCommand[] = [];
|
||||
let teamRegistrationFailures = 0;
|
||||
|
||||
for (const team of teams) {
|
||||
try {
|
||||
const registered = await registerSlashCommands({
|
||||
client,
|
||||
teamId: team.id,
|
||||
creatorUserId: botUserId,
|
||||
callbackUrl,
|
||||
commands: dedupedCommands,
|
||||
log: (msg) => runtime.log?.(msg),
|
||||
});
|
||||
allRegistered.push(...registered);
|
||||
} catch (err) {
|
||||
teamRegistrationFailures += 1;
|
||||
runtime.error?.(
|
||||
`mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (allRegistered.length === 0) {
|
||||
runtime.error?.(
|
||||
"mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive",
|
||||
);
|
||||
} else {
|
||||
if (teamRegistrationFailures > 0) {
|
||||
runtime.error?.(
|
||||
`mattermost: slash command registration completed with ${teamRegistrationFailures} team error(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build trigger→originalName map for accurate command name resolution
|
||||
const triggerMap = new Map<string, string>();
|
||||
for (const cmd of dedupedCommands) {
|
||||
if (cmd.originalName) {
|
||||
triggerMap.set(cmd.trigger, cmd.originalName);
|
||||
}
|
||||
}
|
||||
|
||||
activateSlashCommands({
|
||||
account,
|
||||
commandTokens: allRegistered.map((cmd) => cmd.token).filter(Boolean),
|
||||
registeredCommands: allRegistered,
|
||||
triggerMap,
|
||||
api: { cfg, runtime },
|
||||
log: (msg) => runtime.log?.(msg),
|
||||
});
|
||||
|
||||
runtime.log?.(
|
||||
`mattermost: slash commands registered (${allRegistered.length} commands across ${teams.length} teams, callback=${callbackUrl})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
@@ -1010,6 +1163,37 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
},
|
||||
});
|
||||
|
||||
let slashShutdownCleanup: Promise<void> | null = null;
|
||||
|
||||
// Clean up slash commands on shutdown
|
||||
if (slashEnabled) {
|
||||
const runAbortCleanup = () => {
|
||||
if (slashShutdownCleanup) {
|
||||
return;
|
||||
}
|
||||
// Snapshot registered commands before deactivating state.
|
||||
// This listener may run concurrently with startup in a new process, so we keep
|
||||
// monitor shutdown alive until the remote cleanup completes.
|
||||
const commands = getSlashCommandState(account.accountId)?.registeredCommands ?? [];
|
||||
// Deactivate state immediately to prevent new local dispatches during teardown.
|
||||
deactivateSlashCommands(account.accountId);
|
||||
|
||||
slashShutdownCleanup = cleanupSlashCommands({
|
||||
client,
|
||||
commands,
|
||||
log: (msg) => runtime.log?.(msg),
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`mattermost: slash cleanup failed: ${String(err)}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (opts.abortSignal?.aborted) {
|
||||
runAbortCleanup();
|
||||
} else {
|
||||
opts.abortSignal?.addEventListener("abort", runAbortCleanup, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
await runWithReconnect(connectOnce, {
|
||||
abortSignal: opts.abortSignal,
|
||||
jitterRatio: 0.2,
|
||||
@@ -1021,4 +1205,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
|
||||
},
|
||||
});
|
||||
|
||||
if (slashShutdownCleanup) {
|
||||
await slashShutdownCleanup;
|
||||
}
|
||||
}
|
||||
|
||||
156
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
156
extensions/mattermost/src/mattermost/slash-commands.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import {
|
||||
parseSlashCommandPayload,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveCommandText,
|
||||
resolveSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
describe("slash-commands", () => {
|
||||
it("parses application/x-www-form-urlencoded payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
token: "t1",
|
||||
team_id: "team",
|
||||
channel_id: "ch1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "now",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses application/json payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({
|
||||
token: "t2",
|
||||
team_id: "team",
|
||||
channel_id: "ch2",
|
||||
user_id: "u2",
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
}),
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
token: "t2",
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed payloads missing required fields", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({ token: "t3", command: "/oc_help" }),
|
||||
"application/json",
|
||||
);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves command text with trigger map fallback", () => {
|
||||
const triggerMap = new Map<string, string>([["oc_status", "status"]]);
|
||||
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
|
||||
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
|
||||
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
|
||||
});
|
||||
|
||||
it("normalizes callback path in slash config", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
|
||||
expect(config.callbackPath).toBe("/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("falls back to localhost callback URL for wildcard bind hosts", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "/api/channels/mattermost/command" });
|
||||
const callbackUrl = resolveCallbackUrl({
|
||||
config,
|
||||
gatewayPort: 18789,
|
||||
gatewayHost: "0.0.0.0",
|
||||
});
|
||||
expect(callbackUrl).toBe("http://localhost:18789/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("reuses existing command when trigger already points to callback URL", async () => {
|
||||
const request = vi.fn(async (path: string) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "tok-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const client = { request } as unknown as MattermostClient;
|
||||
|
||||
const result = await registerSlashCommands({
|
||||
client,
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "http://gateway/callback",
|
||||
commands: [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
description: "status",
|
||||
autoComplete: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.managed).toBe(false);
|
||||
expect(result[0]?.id).toBe("cmd-1");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
|
||||
const request = vi.fn(async (path: string, init?: { method?: string }) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-foreign-1",
|
||||
token: "tok-foreign-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "another-bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://foreign/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (init?.method === "POST" || init?.method === "PUT" || init?.method === "DELETE") {
|
||||
throw new Error("should not mutate foreign commands");
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const client = { request } as unknown as MattermostClient;
|
||||
|
||||
const result = await registerSlashCommands({
|
||||
client,
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "http://gateway/callback",
|
||||
commands: [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
description: "status",
|
||||
autoComplete: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
565
extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
565
extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Mattermost native slash command support.
|
||||
*
|
||||
* Registers custom slash commands via the Mattermost REST API and handles
|
||||
* incoming command callbacks via an HTTP endpoint on the gateway.
|
||||
*
|
||||
* Architecture:
|
||||
* - On startup, registers commands with MM via POST /api/v4/commands
|
||||
* - MM sends HTTP POST to callbackUrl when a user invokes a command
|
||||
* - The callback handler reconstructs the text as `/<command> <args>` and
|
||||
* routes it through the standard inbound reply pipeline
|
||||
* - On shutdown, cleans up registered commands via DELETE /api/v4/commands/{id}
|
||||
*/
|
||||
|
||||
import type { MattermostClient } from "./client.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MattermostSlashCommandConfig = {
|
||||
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
|
||||
native: boolean | "auto";
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: boolean | "auto";
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: string;
|
||||
/**
|
||||
* Explicit callback URL override (e.g. behind a reverse proxy).
|
||||
* If not set, auto-derived from baseUrl + gateway port + callbackPath.
|
||||
*/
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
export type MattermostCommandSpec = {
|
||||
trigger: string;
|
||||
description: string;
|
||||
autoComplete: boolean;
|
||||
autoCompleteHint?: string;
|
||||
/** Original command name (for skill commands that start with oc_) */
|
||||
originalName?: string;
|
||||
};
|
||||
|
||||
export type MattermostRegisteredCommand = {
|
||||
id: string;
|
||||
trigger: string;
|
||||
teamId: string;
|
||||
token: string;
|
||||
/** True when this process created the command and should delete it on shutdown. */
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload sent by Mattermost when a slash command is invoked.
|
||||
* Can arrive as application/x-www-form-urlencoded or application/json.
|
||||
*/
|
||||
export type MattermostSlashCommandPayload = {
|
||||
token: string;
|
||||
team_id: string;
|
||||
team_domain?: string;
|
||||
channel_id: string;
|
||||
channel_name?: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
command: string; // e.g. "/status"
|
||||
text: string; // args after the trigger word
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response format for Mattermost slash command callbacks.
|
||||
*/
|
||||
export type MattermostSlashCommandResponse = {
|
||||
response_type?: "ephemeral" | "in_channel";
|
||||
text: string;
|
||||
username?: string;
|
||||
icon_url?: string;
|
||||
goto_location?: string;
|
||||
attachments?: unknown[];
|
||||
};
|
||||
|
||||
// ─── MM API types ────────────────────────────────────────────────────────────
|
||||
|
||||
type MattermostCommandCreate = {
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: "P" | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
token?: string;
|
||||
creator_id?: string;
|
||||
};
|
||||
|
||||
type MattermostCommandUpdate = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: "P" | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
};
|
||||
|
||||
type MattermostCommandResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: string;
|
||||
url: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
creator_id?: string;
|
||||
create_at?: number;
|
||||
update_at?: number;
|
||||
delete_at?: number;
|
||||
};
|
||||
|
||||
// ─── Default commands ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Built-in OpenClaw commands to register as native slash commands.
|
||||
* These mirror the text-based commands already handled by the gateway.
|
||||
*/
|
||||
export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
originalName: "status",
|
||||
description: "Show session status (model, usage, uptime)",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_model",
|
||||
originalName: "model",
|
||||
description: "View or change the current model",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[model-name]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_new",
|
||||
originalName: "new",
|
||||
description: "Start a new conversation session",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_help",
|
||||
originalName: "help",
|
||||
description: "Show available commands",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_think",
|
||||
originalName: "think",
|
||||
description: "Set thinking/reasoning level",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[off|low|medium|high]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_reasoning",
|
||||
originalName: "reasoning",
|
||||
description: "Toggle reasoning mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_verbose",
|
||||
originalName: "verbose",
|
||||
description: "Toggle verbose mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Command registration ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List existing custom slash commands for a team.
|
||||
*/
|
||||
export async function listMattermostCommands(
|
||||
client: MattermostClient,
|
||||
teamId: string,
|
||||
): Promise<MattermostCommandResponse[]> {
|
||||
return await client.request<MattermostCommandResponse[]>(
|
||||
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom slash command on a Mattermost team.
|
||||
*/
|
||||
export async function createMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandCreate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>("/commands", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom slash command.
|
||||
*/
|
||||
export async function deleteMattermostCommand(
|
||||
client: MattermostClient,
|
||||
commandId: string,
|
||||
): Promise<void> {
|
||||
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom slash command.
|
||||
*/
|
||||
export async function updateMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandUpdate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>(
|
||||
`/commands/${encodeURIComponent(params.id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all OpenClaw slash commands for a given team.
|
||||
* Skips commands that are already registered with the same trigger + callback URL.
|
||||
* Returns the list of newly created command IDs.
|
||||
*/
|
||||
export async function registerSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
teamId: string;
|
||||
creatorUserId: string;
|
||||
callbackUrl: string;
|
||||
commands: MattermostCommandSpec[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<MattermostRegisteredCommand[]> {
|
||||
const { client, teamId, creatorUserId, callbackUrl, commands, log } = params;
|
||||
const normalizedCreatorUserId = creatorUserId.trim();
|
||||
if (!normalizedCreatorUserId) {
|
||||
throw new Error("creatorUserId is required for slash command reconciliation");
|
||||
}
|
||||
|
||||
// Fetch existing commands to avoid duplicates
|
||||
let existing: MattermostCommandResponse[] = [];
|
||||
try {
|
||||
existing = await listMattermostCommands(client, teamId);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to list existing commands: ${String(err)}`);
|
||||
// Fail closed: if we can't list existing commands, we should not attempt to
|
||||
// create/update anything because we may create duplicates and end up with an
|
||||
// empty/partial token set (causing callbacks to be rejected until restart).
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existingByTrigger = new Map<string, MattermostCommandResponse[]>();
|
||||
for (const cmd of existing) {
|
||||
const list = existingByTrigger.get(cmd.trigger) ?? [];
|
||||
list.push(cmd);
|
||||
existingByTrigger.set(cmd.trigger, list);
|
||||
}
|
||||
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
|
||||
for (const spec of commands) {
|
||||
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
|
||||
const ownedCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
|
||||
);
|
||||
const foreignCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId,
|
||||
);
|
||||
|
||||
if (ownedCommands.length === 0 && foreignCommands.length > 0) {
|
||||
log?.(
|
||||
`mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownedCommands.length > 1) {
|
||||
log?.(
|
||||
`mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingCmd = ownedCommands[0];
|
||||
|
||||
// Already registered with the correct callback URL
|
||||
if (existingCmd && existingCmd.url === callbackUrl) {
|
||||
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
|
||||
registered.push({
|
||||
id: existingCmd.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: existingCmd.token,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exists but points to a different URL: attempt to reconcile by updating
|
||||
// (useful during callback URL migrations).
|
||||
if (existingCmd && existingCmd.url !== callbackUrl) {
|
||||
log?.(
|
||||
`mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`,
|
||||
);
|
||||
try {
|
||||
const updated = await updateMattermostCommand(client, {
|
||||
id: existingCmd.id,
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: "P",
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
registered.push({
|
||||
id: updated.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: updated.token,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
} catch (err) {
|
||||
log?.(
|
||||
`mattermost: failed to update command /${spec.trigger} (id=${existingCmd.id}): ${String(err)}`,
|
||||
);
|
||||
// Fallback: try delete+recreate for commands owned by this bot user.
|
||||
try {
|
||||
await deleteMattermostCommand(client, existingCmd.id);
|
||||
log?.(`mattermost: deleted stale command /${spec.trigger} (id=${existingCmd.id})`);
|
||||
} catch (deleteErr) {
|
||||
log?.(
|
||||
`mattermost: failed to delete stale command /${spec.trigger} (id=${existingCmd.id}): ${String(deleteErr)}`,
|
||||
);
|
||||
// Can't reconcile; skip this command.
|
||||
continue;
|
||||
}
|
||||
// Continue on to create below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createMattermostCommand(client, {
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: "P",
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);
|
||||
registered.push({
|
||||
id: created.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: created.token,
|
||||
managed: true,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to register command /${spec.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all registered slash commands.
|
||||
*/
|
||||
export async function cleanupSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
commands: MattermostRegisteredCommand[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<void> {
|
||||
const { client, commands, log } = params;
|
||||
for (const cmd of commands) {
|
||||
if (!cmd.managed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await deleteMattermostCommand(client, cmd.id);
|
||||
log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Callback parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Mattermost slash command callback payload from a URL-encoded or JSON body.
|
||||
*/
|
||||
export function parseSlashCommandPayload(
|
||||
body: string,
|
||||
contentType?: string,
|
||||
): MattermostSlashCommandPayload | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (contentType?.includes("application/json")) {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
|
||||
// Validate required fields (same checks as the form-encoded branch)
|
||||
const token = typeof parsed.token === "string" ? parsed.token : "";
|
||||
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "";
|
||||
const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : "";
|
||||
const userId = typeof parsed.user_id === "string" ? parsed.user_id : "";
|
||||
const command = typeof parsed.command === "string" ? parsed.command : "";
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined,
|
||||
user_id: userId,
|
||||
user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined,
|
||||
command,
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined,
|
||||
response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: application/x-www-form-urlencoded
|
||||
const params = new URLSearchParams(body);
|
||||
const token = params.get("token");
|
||||
const teamId = params.get("team_id");
|
||||
const channelId = params.get("channel_id");
|
||||
const userId = params.get("user_id");
|
||||
const command = params.get("command");
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: params.get("team_domain") ?? undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: params.get("channel_name") ?? undefined,
|
||||
user_id: userId,
|
||||
user_name: params.get("user_name") ?? undefined,
|
||||
command,
|
||||
text: params.get("text") ?? "",
|
||||
trigger_id: params.get("trigger_id") ?? undefined,
|
||||
response_url: params.get("response_url") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the trigger word back to the original OpenClaw command name.
|
||||
* e.g. "oc_status" -> "/status", "oc_model" -> "/model"
|
||||
*/
|
||||
export function resolveCommandText(
|
||||
trigger: string,
|
||||
text: string,
|
||||
triggerMap?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
// Use the trigger map if available for accurate name resolution
|
||||
const commandName =
|
||||
triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger);
|
||||
const args = text.trim();
|
||||
return args ? `/${commandName} ${args}` : `/${commandName}`;
|
||||
}
|
||||
|
||||
// ─── Config resolution ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
/**
|
||||
* Ensure the callback path starts with a leading `/` to prevent
|
||||
* malformed URLs like `http://host:portapi/...`.
|
||||
*/
|
||||
function normalizeCallbackPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return DEFAULT_CALLBACK_PATH;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function resolveSlashCommandConfig(
|
||||
raw?: Partial<MattermostSlashCommandConfig>,
|
||||
): MattermostSlashCommandConfig {
|
||||
return {
|
||||
native: raw?.native ?? "auto",
|
||||
nativeSkills: raw?.nativeSkills ?? "auto",
|
||||
callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH),
|
||||
callbackUrl: raw?.callbackUrl?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): boolean {
|
||||
if (config.native === true) {
|
||||
return true;
|
||||
}
|
||||
if (config.native === false) {
|
||||
return false;
|
||||
}
|
||||
// "auto" defaults to false for mattermost (opt-in)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the callback URL that Mattermost will POST to when a command is invoked.
|
||||
*/
|
||||
export function resolveCallbackUrl(params: {
|
||||
config: MattermostSlashCommandConfig;
|
||||
gatewayPort: number;
|
||||
gatewayHost?: string;
|
||||
}): string {
|
||||
if (params.config.callbackUrl) {
|
||||
return params.config.callbackUrl;
|
||||
}
|
||||
|
||||
const isWildcardBindHost = (rawHost: string): boolean => {
|
||||
const trimmed = rawHost.trim();
|
||||
if (!trimmed) return false;
|
||||
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
|
||||
// NOTE: Wildcard listen hosts are valid bind addresses but are not routable callback
|
||||
// destinations. Don't emit callback URLs like http://0.0.0.0:3015/... or http://[::]:3015/...
|
||||
// when an operator sets gateway.customBindHost.
|
||||
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||
};
|
||||
|
||||
let host =
|
||||
params.gatewayHost && !isWildcardBindHost(params.gatewayHost)
|
||||
? params.gatewayHost
|
||||
: "localhost";
|
||||
const path = normalizeCallbackPath(params.config.callbackPath);
|
||||
|
||||
// Bracket IPv6 literals so the URL is valid: http://[::1]:3015/...
|
||||
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
return `http://${host}:${params.gatewayPort}${path}`;
|
||||
}
|
||||
130
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
130
extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import { createSlashCommandHttpHandler } from "./slash-http.js";
|
||||
|
||||
function createRequest(params: {
|
||||
method?: string;
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
}): IncomingMessage {
|
||||
const req = new PassThrough();
|
||||
const incoming = req as unknown as IncomingMessage;
|
||||
incoming.method = params.method ?? "POST";
|
||||
incoming.headers = {
|
||||
"content-type": params.contentType ?? "application/x-www-form-urlencoded",
|
||||
};
|
||||
process.nextTick(() => {
|
||||
if (params.body) {
|
||||
req.write(params.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
getBody: () => string;
|
||||
getHeaders: () => Map<string, string>;
|
||||
} {
|
||||
let body = "";
|
||||
const headers = new Map<string, string>();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
body = chunk ? String(chunk) : "";
|
||||
},
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
getBody: () => body,
|
||||
getHeaders: () => headers,
|
||||
};
|
||||
}
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
describe("slash-http", () => {
|
||||
it("rejects non-POST methods", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
});
|
||||
const req = createRequest({ method: "GET", body: "" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(405);
|
||||
expect(response.getBody()).toBe("Method Not Allowed");
|
||||
expect(response.getHeaders().get("allow")).toBe("POST");
|
||||
});
|
||||
|
||||
it("rejects malformed payloads", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
});
|
||||
const req = createRequest({ body: "token=abc&command=%2Foc_status" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(400);
|
||||
expect(response.getBody()).toContain("Invalid slash command payload");
|
||||
});
|
||||
|
||||
it("fails closed when no command tokens are registered", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set<string>(),
|
||||
});
|
||||
const req = createRequest({
|
||||
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("rejects unknown command tokens", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["known-token"]),
|
||||
});
|
||||
const req = createRequest({
|
||||
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
});
|
||||
657
extensions/mattermost/src/mattermost/slash-http.ts
Normal file
657
extensions/mattermost/src/mattermost/slash-http.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* HTTP callback handler for Mattermost slash commands.
|
||||
*
|
||||
* Receives POST requests from Mattermost when a slash command is invoked,
|
||||
* validates the token, and routes the command through the standard inbound pipeline.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
isDangerousNameMatchingEnabled,
|
||||
logTypingFailure,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
} from "./client.js";
|
||||
import {
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowList,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
} from "./monitor-auth.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import {
|
||||
parseSlashCommandPayload,
|
||||
resolveCommandText,
|
||||
type MattermostSlashCommandResponse,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
type SlashHttpHandlerParams = {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
/** Expected token from registered commands (for validation). */
|
||||
commandTokens: Set<string>;
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap?: ReadonlyMap<string, string>;
|
||||
log?: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the full request body as a string.
|
||||
*/
|
||||
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJsonResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
body: MattermostSlashCommandResponse,
|
||||
) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
type SlashInvocationAuth = {
|
||||
ok: boolean;
|
||||
denyResponse?: MattermostSlashCommandResponse;
|
||||
commandAuthorized: boolean;
|
||||
channelInfo: MattermostChannel | null;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
async function authorizeSlashInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<SlashInvocationAuth> {
|
||||
const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
// Resolve channel info so we can enforce DM vs group/channel policies.
|
||||
let channelInfo: MattermostChannel | null = null;
|
||||
try {
|
||||
channelInfo = await fetchMattermostChannel(client, channelId);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: slash channel lookup failed for ${channelId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (!channelInfo) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Temporary error: unable to determine channel type. Please try again.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo: null,
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: `#${channelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const channelType = channelInfo.type ?? undefined;
|
||||
const isDirectMessage = channelType?.toUpperCase() === "D";
|
||||
const kind: SlashInvocationAuth["kind"] = isDirectMessage
|
||||
? "direct"
|
||||
: channelInfo
|
||||
? channelType?.toUpperCase() === "G"
|
||||
? "group"
|
||||
: "channel"
|
||||
: "channel";
|
||||
|
||||
const chatType = kind === "direct" ? "direct" : kind === "group" ? "group" : "channel";
|
||||
|
||||
const channelName = channelInfo?.name ?? "";
|
||||
const channelDisplay = channelInfo?.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
|
||||
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await core.channel.pairing
|
||||
.readAllowFromStore({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.catch(() => []),
|
||||
);
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
|
||||
const commandGroupAllowFrom =
|
||||
kind === "direct"
|
||||
? effectiveGroupAllowFrom
|
||||
: configGroupAllowFrom.length > 0
|
||||
? configGroupAllowFrom
|
||||
: configAllowFrom;
|
||||
|
||||
const senderAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandDmAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: commandGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
|
||||
const commandAuthorized =
|
||||
kind === "direct"
|
||||
? dmPolicy === "open" || senderAllowedForCommands
|
||||
: commandGate.commandAuthorized;
|
||||
|
||||
// DM policy enforcement
|
||||
if (kind === "direct") {
|
||||
if (dmPolicy === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "This bot is not accepting direct messages.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "mattermost",
|
||||
idLine: `Your Mattermost user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Group/channel policy enforcement
|
||||
if (groupPolicy === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are disabled in channels.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not configured for this channel (no allowlist).",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
if (!groupAllowedForCommands) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (commandGate.shouldBlock) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the HTTP request handler for Mattermost slash command callbacks.
|
||||
*
|
||||
* This handler is registered as a plugin HTTP route and receives POSTs
|
||||
* from the Mattermost server when a user invokes a registered slash command.
|
||||
*/
|
||||
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
|
||||
const { account, cfg, runtime, commandTokens, triggerMap, log } = params;
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024; // 64KB
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
body = await readBody(req, MAX_BODY_BYTES);
|
||||
} catch {
|
||||
res.statusCode = 413;
|
||||
res.end("Payload Too Large");
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"] ?? "";
|
||||
const payload = parseSlashCommandPayload(body, contentType);
|
||||
if (!payload) {
|
||||
sendJsonResponse(res, 400, {
|
||||
response_type: "ephemeral",
|
||||
text: "Invalid slash command payload.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token — fail closed: reject when no tokens are registered
|
||||
// (e.g. registration failed or startup was partial)
|
||||
if (commandTokens.size === 0 || !commandTokens.has(payload.token)) {
|
||||
sendJsonResponse(res, 401, {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract command info
|
||||
const trigger = payload.command.replace(/^\//, "").trim();
|
||||
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
|
||||
const channelId = payload.channel_id;
|
||||
const senderId = payload.user_id;
|
||||
const senderName = payload.user_name ?? senderId;
|
||||
|
||||
const client = createMattermostClient({
|
||||
baseUrl: account.baseUrl ?? "",
|
||||
botToken: account.botToken ?? "",
|
||||
});
|
||||
|
||||
const auth = await authorizeSlashInvocation({
|
||||
account,
|
||||
cfg,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
log,
|
||||
});
|
||||
|
||||
if (!auth.ok) {
|
||||
sendJsonResponse(
|
||||
res,
|
||||
200,
|
||||
auth.denyResponse ?? { response_type: "ephemeral", text: "Unauthorized." },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(`mattermost: slash command /${trigger} from ${senderName} in ${channelId}`);
|
||||
|
||||
// Acknowledge immediately — we'll send the actual reply asynchronously
|
||||
sendJsonResponse(res, 200, {
|
||||
response_type: "ephemeral",
|
||||
text: "Processing...",
|
||||
});
|
||||
|
||||
// Now handle the command asynchronously (post reply as a message)
|
||||
try {
|
||||
await handleSlashCommandAsync({
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId: payload.team_id,
|
||||
triggerId: payload.trigger_id,
|
||||
kind: auth.kind,
|
||||
chatType: auth.chatType,
|
||||
channelName: auth.channelName,
|
||||
channelDisplay: auth.channelDisplay,
|
||||
roomLabel: auth.roomLabel,
|
||||
commandAuthorized: auth.commandAuthorized,
|
||||
log,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: slash command handler error: ${String(err)}`);
|
||||
try {
|
||||
const to = `channel:${channelId}`;
|
||||
await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", {
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch {
|
||||
// best-effort error reply
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSlashCommandAsync(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
teamId: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
commandAuthorized: boolean;
|
||||
triggerId?: string;
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
commandAuthorized,
|
||||
triggerId,
|
||||
log,
|
||||
} = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "direct" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel =
|
||||
kind === "direct"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
|
||||
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
|
||||
// Build inbound context — the command text is the body
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: commandText,
|
||||
BodyForAgent: commandText,
|
||||
RawBody: commandText,
|
||||
CommandBody: commandText,
|
||||
From:
|
||||
kind === "direct"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: triggerId ?? `slash-${Date.now()}`,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
OriginatingTo: to,
|
||||
});
|
||||
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendMattermostTyping(client, { channelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log?.(message),
|
||||
channel: "mattermost",
|
||||
target: channelId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
if (mediaUrls.length === 0) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(
|
||||
cfg,
|
||||
"mattermost",
|
||||
account.accountId,
|
||||
);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||
if (!chunk) continue;
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaUrls) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageMattermost(to, caption, {
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`delivered slash reply to ${to}`);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`mattermost slash ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
});
|
||||
|
||||
await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
42
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal file
42
extensions/mattermost/src/mattermost/slash-state.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activateSlashCommands,
|
||||
deactivateSlashCommands,
|
||||
resolveSlashHandlerForToken,
|
||||
} from "./slash-state.js";
|
||||
|
||||
describe("slash-state token routing", () => {
|
||||
it("returns single match when token belongs to one account", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: { accountId: "a1" } as any,
|
||||
commandTokens: ["tok-a"],
|
||||
registeredCommands: [],
|
||||
api: { cfg: {} as any, runtime: {} as any },
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-a");
|
||||
expect(match.kind).toBe("single");
|
||||
expect(match.accountIds).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("returns ambiguous when same token exists in multiple accounts", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: { accountId: "a1" } as any,
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: { cfg: {} as any, runtime: {} as any },
|
||||
});
|
||||
activateSlashCommands({
|
||||
account: { accountId: "a2" } as any,
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: { cfg: {} as any, runtime: {} as any },
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-shared");
|
||||
expect(match.kind).toBe("ambiguous");
|
||||
expect(match.accountIds?.sort()).toEqual(["a1", "a2"]);
|
||||
});
|
||||
});
|
||||
313
extensions/mattermost/src/mattermost/slash-state.ts
Normal file
313
extensions/mattermost/src/mattermost/slash-state.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Shared state for Mattermost slash commands.
|
||||
*
|
||||
* Bridges the plugin registration phase (HTTP route) with the monitor phase
|
||||
* (command registration with MM API). The HTTP handler needs to know which
|
||||
* tokens are valid, and the monitor needs to store registered command IDs.
|
||||
*
|
||||
* State is kept per-account so that multi-account deployments don't
|
||||
* overwrite each other's tokens, registered commands, or handlers.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
|
||||
import { createSlashCommandHttpHandler } from "./slash-http.js";
|
||||
|
||||
// ─── Per-account state ───────────────────────────────────────────────────────
|
||||
|
||||
export type SlashCommandAccountState = {
|
||||
/** Tokens from registered commands, used for validation. */
|
||||
commandTokens: Set<string>;
|
||||
/** Registered command IDs for cleanup on shutdown. */
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
/** Current HTTP handler for this account. */
|
||||
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | null;
|
||||
/** The account that activated slash commands. */
|
||||
account: ResolvedMattermostAccount;
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Map from accountId → per-account slash command state. */
|
||||
const accountStates = new Map<string, SlashCommandAccountState>();
|
||||
|
||||
export function resolveSlashHandlerForToken(token: string): {
|
||||
kind: "none" | "single" | "ambiguous";
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
accountIds?: string[];
|
||||
} {
|
||||
const matches: Array<{
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
}> = [];
|
||||
|
||||
for (const [accountId, state] of accountStates) {
|
||||
if (state.commandTokens.has(token) && state.handler) {
|
||||
matches.push({ accountId, handler: state.handler });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return { kind: "single", handler: matches[0]!.handler, accountIds: [matches[0]!.accountId] };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
accountIds: matches.map((entry) => entry.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slash command state for a specific account, or null if not activated.
|
||||
*/
|
||||
export function getSlashCommandState(accountId: string): SlashCommandAccountState | null {
|
||||
return accountStates.get(accountId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active slash command account states.
|
||||
*/
|
||||
export function getAllSlashCommandStates(): ReadonlyMap<string, SlashCommandAccountState> {
|
||||
return accountStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate slash commands for a specific account.
|
||||
* Called from the monitor after bot connects.
|
||||
*/
|
||||
export function activateSlashCommands(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
commandTokens: string[];
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
triggerMap?: Map<string, string>;
|
||||
api: {
|
||||
cfg: import("openclaw/plugin-sdk").OpenClawConfig;
|
||||
runtime: import("openclaw/plugin-sdk").RuntimeEnv;
|
||||
};
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const { account, commandTokens, registeredCommands, triggerMap, api, log } = params;
|
||||
const accountId = account.accountId;
|
||||
|
||||
const tokenSet = new Set(commandTokens);
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account,
|
||||
cfg: api.cfg,
|
||||
runtime: api.runtime,
|
||||
commandTokens: tokenSet,
|
||||
triggerMap,
|
||||
log,
|
||||
});
|
||||
|
||||
accountStates.set(accountId, {
|
||||
commandTokens: tokenSet,
|
||||
registeredCommands,
|
||||
handler,
|
||||
account,
|
||||
triggerMap: triggerMap ?? new Map(),
|
||||
});
|
||||
|
||||
log?.(
|
||||
`mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate slash commands for a specific account (on shutdown/disconnect).
|
||||
*/
|
||||
export function deactivateSlashCommands(accountId?: string) {
|
||||
if (accountId) {
|
||||
const state = accountStates.get(accountId);
|
||||
if (state) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
accountStates.delete(accountId);
|
||||
}
|
||||
} else {
|
||||
// Deactivate all accounts (full shutdown)
|
||||
for (const [, state] of accountStates) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
}
|
||||
accountStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the HTTP route for slash command callbacks.
|
||||
* Called during plugin registration.
|
||||
*
|
||||
* The single HTTP route dispatches to the correct per-account handler
|
||||
* by matching the inbound token against each account's registered tokens.
|
||||
*/
|
||||
export function registerSlashCommandRoute(api: OpenClawPluginApi) {
|
||||
const mmConfig = api.config.channels?.mattermost as Record<string, unknown> | undefined;
|
||||
|
||||
// Collect callback paths from both top-level and per-account config.
|
||||
// Command registration uses account.config.commands, so the HTTP route
|
||||
// registration must include any account-specific callbackPath overrides.
|
||||
// Also extract the pathname from an explicit callbackUrl when it differs
|
||||
// from callbackPath, so that Mattermost callbacks hit a registered route.
|
||||
const callbackPaths = new Set<string>();
|
||||
|
||||
const addCallbackPaths = (
|
||||
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | undefined,
|
||||
) => {
|
||||
const resolved = resolveSlashCommandConfig(raw);
|
||||
callbackPaths.add(resolved.callbackPath);
|
||||
if (resolved.callbackUrl) {
|
||||
try {
|
||||
const urlPath = new URL(resolved.callbackUrl).pathname;
|
||||
if (urlPath && urlPath !== resolved.callbackPath) {
|
||||
callbackPaths.add(urlPath);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — ignore, will be caught during registration
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const commandsRaw = mmConfig?.commands as
|
||||
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
addCallbackPaths(commandsRaw);
|
||||
|
||||
const accountsRaw = (mmConfig?.accounts ?? {}) as Record<string, unknown>;
|
||||
for (const accountId of Object.keys(accountsRaw)) {
|
||||
const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined;
|
||||
const accountCommandsRaw = accountCfg?.commands as
|
||||
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
addCallbackPaths(accountCommandsRaw);
|
||||
}
|
||||
|
||||
const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (accountStates.size === 0) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to peek at the token to route to the right account handler.
|
||||
// Since each account handler also validates the token, we find the
|
||||
// account whose token set contains the inbound token and delegate.
|
||||
|
||||
// If there's only one active account (common case), route directly.
|
||||
if (accountStates.size === 1) {
|
||||
const [, state] = [...accountStates.entries()][0]!;
|
||||
if (!state.handler) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await state.handler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-account: buffer the body, find the matching account by token,
|
||||
// then replay the request to the correct handler.
|
||||
const chunks: Buffer[] = [];
|
||||
const MAX_BODY = 64 * 1024;
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > MAX_BODY) {
|
||||
res.statusCode = 413;
|
||||
res.end("Payload Too Large");
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const bodyStr = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
// Parse just the token to find the right account
|
||||
let token: string | null = null;
|
||||
const ct = req.headers["content-type"] ?? "";
|
||||
try {
|
||||
if (ct.includes("application/json")) {
|
||||
token = (JSON.parse(bodyStr) as { token?: string }).token ?? null;
|
||||
} else {
|
||||
token = new URLSearchParams(bodyStr).get("token");
|
||||
}
|
||||
} catch {
|
||||
// parse failed — will be caught by handler
|
||||
}
|
||||
|
||||
const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const };
|
||||
|
||||
if (match.kind === "none") {
|
||||
// No matching account — reject
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.kind === "ambiguous") {
|
||||
api.logger.warn?.(
|
||||
`mattermost: slash callback token matched multiple accounts (${match.accountIds?.join(", ")})`,
|
||||
);
|
||||
res.statusCode = 409;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Conflict: command token is not unique across accounts.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedHandler = match.handler!;
|
||||
|
||||
// Replay: create a synthetic readable that re-emits the buffered body
|
||||
const { Readable } = await import("node:stream");
|
||||
const syntheticReq = new Readable({
|
||||
read() {
|
||||
this.push(Buffer.from(bodyStr, "utf8"));
|
||||
this.push(null);
|
||||
},
|
||||
}) as IncomingMessage;
|
||||
|
||||
// Copy necessary IncomingMessage properties
|
||||
syntheticReq.method = req.method;
|
||||
syntheticReq.url = req.url;
|
||||
syntheticReq.headers = req.headers;
|
||||
|
||||
await matchedHandler(syntheticReq, res);
|
||||
};
|
||||
|
||||
for (const callbackPath of callbackPaths) {
|
||||
api.registerHttpRoute({
|
||||
path: callbackPath,
|
||||
auth: "plugin",
|
||||
handler: routeHandler,
|
||||
});
|
||||
api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export type MattermostAccountConfig = {
|
||||
/** Enable message reaction actions. Default: true. */
|
||||
reactions?: boolean;
|
||||
};
|
||||
/** Native slash command configuration. */
|
||||
commands?: {
|
||||
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||
native?: boolean | "auto";
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills?: boolean | "auto";
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath?: string;
|
||||
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||
callbackUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostConfig = {
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
"openclaw": ">=2026.3.1"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87",
|
||||
"@tloncorp/tlon-skill": "0.1.9",
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
"@urbit/http-api": "^3.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"commander": "^14.0.3",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"undici": "7.22.0"
|
||||
"undici": "7.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"zca-js": "2.1.1"
|
||||
"zca-js": "2.1.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.3.2-beta.1",
|
||||
"version": "2026.3.3",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
@@ -173,7 +173,6 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.5",
|
||||
"@larksuiteoapi/node-sdk": "^1.59.0",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.55.3",
|
||||
@@ -197,7 +196,6 @@
|
||||
"express": "^5.2.1",
|
||||
"file-type": "^21.3.0",
|
||||
"gaxios": "7.1.3",
|
||||
"google-auth-library": "10.6.1",
|
||||
"grammy": "^1.41.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
@@ -260,7 +258,7 @@
|
||||
"minimumReleaseAge": 2880,
|
||||
"overrides": {
|
||||
"hono": "4.11.10",
|
||||
"fast-xml-parser": "5.3.8",
|
||||
"fast-xml-parser": "5.3.6",
|
||||
"request": "npm:@cypress/request@3.0.10",
|
||||
"request-promise": "npm:@cypress/request-promise@5.0.0",
|
||||
"form-data": "2.5.4",
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -6,7 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
hono: 4.11.10
|
||||
fast-xml-parser: 5.3.8
|
||||
fast-xml-parser: 5.3.6
|
||||
request: npm:@cypress/request@3.0.10
|
||||
request-promise: npm:@cypress/request-promise@5.0.0
|
||||
form-data: 2.5.4
|
||||
@@ -45,9 +45,6 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.59.0
|
||||
version: 1.59.0
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
@@ -120,9 +117,6 @@ importers:
|
||||
gaxios:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
google-auth-library:
|
||||
specifier: 10.6.1
|
||||
version: 10.6.1
|
||||
grammy:
|
||||
specifier: ^1.41.0
|
||||
version: 1.41.0
|
||||
@@ -270,7 +264,11 @@ importers:
|
||||
specifier: 0.1.15
|
||||
version: 0.1.15(zod@4.3.6)
|
||||
|
||||
extensions/bluebubbles: {}
|
||||
extensions/bluebubbles:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
@@ -347,21 +345,39 @@ importers:
|
||||
specifier: ^10.6.1
|
||||
version: 10.6.1
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.1'
|
||||
version: 2026.3.1(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
extensions/irc: {}
|
||||
extensions/irc:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/line: {}
|
||||
|
||||
extensions/llm-task: {}
|
||||
extensions/llm-task:
|
||||
dependencies:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.48
|
||||
version: 0.34.48
|
||||
ajv:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
|
||||
extensions/lobster: {}
|
||||
extensions/lobster:
|
||||
dependencies:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.48
|
||||
version: 0.34.48
|
||||
|
||||
extensions/matrix:
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: 0.55.3
|
||||
version: 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
@@ -378,13 +394,20 @@ importers:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/mattermost: {}
|
||||
extensions/mattermost:
|
||||
dependencies:
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.1'
|
||||
version: 2026.3.1(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@@ -409,7 +432,11 @@ importers:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
|
||||
extensions/nextcloud-talk: {}
|
||||
extensions/nextcloud-talk:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/nostr:
|
||||
dependencies:
|
||||
@@ -448,6 +475,9 @@ importers:
|
||||
'@urbit/http-api':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/twitch:
|
||||
dependencies:
|
||||
@@ -469,6 +499,9 @@ importers:
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.48
|
||||
version: 0.34.48
|
||||
commander:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
@@ -483,6 +516,9 @@ importers:
|
||||
undici:
|
||||
specifier: 7.22.0
|
||||
version: 7.22.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
extensions/zalouser:
|
||||
dependencies:
|
||||
@@ -492,6 +528,9 @@ importers:
|
||||
zca-js:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
packages/clawdbot:
|
||||
dependencies:
|
||||
@@ -3963,8 +4002,8 @@ packages:
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-xml-parser@5.3.8:
|
||||
resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==}
|
||||
fast-xml-parser@5.3.6:
|
||||
resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==}
|
||||
hasBin: true
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
@@ -4942,8 +4981,8 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openclaw@2026.3.2:
|
||||
resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==}
|
||||
openclaw@2026.3.1:
|
||||
resolution: {integrity: sha512-7Pt5ykhaYa8TYpLWnBhaMg6Lp6kfk3rMKgqJ3WWESKM9BizYu1fkH/rF9BLeXlsNASgZdLp4oR8H0XfvIIoXIg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -6711,7 +6750,7 @@ snapshots:
|
||||
'@aws-sdk/xml-builder@3.972.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.13.0
|
||||
fast-xml-parser: 5.3.8
|
||||
fast-xml-parser: 5.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.3': {}
|
||||
@@ -10082,7 +10121,7 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-parser@5.3.8:
|
||||
fast-xml-parser@5.3.6:
|
||||
dependencies:
|
||||
strnum: 2.2.0
|
||||
|
||||
@@ -11154,7 +11193,7 @@ snapshots:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
|
||||
openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
openclaw@2026.3.1(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.11.10)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.1000.0
|
||||
@@ -11209,7 +11248,6 @@ snapshots:
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
strip-ansi: 7.2.0
|
||||
tar: 7.5.9
|
||||
tslog: 4.10.2
|
||||
undici: 7.22.0
|
||||
|
||||
@@ -37,8 +37,10 @@ case "$cmd" in
|
||||
unit="${args[1]:-}"
|
||||
unit_path="$HOME/.config/systemd/user/${unit}"
|
||||
if [ -f "$unit_path" ]; then
|
||||
echo "enabled"
|
||||
exit 0
|
||||
fi
|
||||
echo "disabled" >&2
|
||||
exit 1
|
||||
;;
|
||||
show)
|
||||
|
||||
@@ -423,7 +423,9 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
|
||||
if (state === "final") {
|
||||
this.finishPrompt(pending.sessionId, pending, "end_turn");
|
||||
const rawStopReason = payload.stopReason as string | undefined;
|
||||
const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn";
|
||||
this.finishPrompt(pending.sessionId, pending, stopReason);
|
||||
return;
|
||||
}
|
||||
if (state === "aborted") {
|
||||
|
||||
64
src/agents/bash-tools.exec-runtime.test.ts
Normal file
64
src/agents/bash-tools.exec-runtime.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../infra/heartbeat-wake.js", () => ({
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
||||
|
||||
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
||||
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
||||
|
||||
describe("emitExecSystemEvent", () => {
|
||||
beforeEach(() => {
|
||||
requestHeartbeatNowMock.mockClear();
|
||||
enqueueSystemEventMock.mockClear();
|
||||
});
|
||||
|
||||
it("scopes heartbeat wake to the event session key", () => {
|
||||
emitExecSystemEvent("Exec finished", {
|
||||
sessionKey: "agent:ops:main",
|
||||
contextKey: "exec:run-1",
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
|
||||
sessionKey: "agent:ops:main",
|
||||
contextKey: "exec:run-1",
|
||||
});
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
|
||||
reason: "exec-event",
|
||||
sessionKey: "agent:ops:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps wake unscoped for non-agent session keys", () => {
|
||||
emitExecSystemEvent("Exec finished", {
|
||||
sessionKey: "global",
|
||||
contextKey: "exec:run-global",
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
|
||||
sessionKey: "global",
|
||||
contextKey: "exec:run-global",
|
||||
});
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
|
||||
reason: "exec-event",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores events without a session key", () => {
|
||||
emitExecSystemEvent("Exec finished", {
|
||||
sessionKey: " ",
|
||||
contextKey: "exec:run-2",
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { scopedHeartbeatWakeOptions } from "../routing/session-key.js";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
@@ -239,7 +240,9 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile
|
||||
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
|
||||
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
|
||||
enqueueSystemEvent(summary, { sessionKey });
|
||||
requestHeartbeatNow({ reason: `exec:${session.id}:exit` });
|
||||
requestHeartbeatNow(
|
||||
scopedHeartbeatWakeOptions(sessionKey, { reason: `exec:${session.id}:exit` }),
|
||||
);
|
||||
}
|
||||
|
||||
export function createApprovalSlug(id: string) {
|
||||
@@ -265,7 +268,7 @@ export function emitExecSystemEvent(
|
||||
return;
|
||||
}
|
||||
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
|
||||
requestHeartbeatNow({ reason: "exec-event" });
|
||||
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
|
||||
}
|
||||
|
||||
export async function runExecProcess(opts: {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resetHeartbeatWakeStateForTests,
|
||||
setHeartbeatWakeHandler,
|
||||
} from "../infra/heartbeat-wake.js";
|
||||
import { applyPathPrepend, findPathKey } from "../infra/path-prepend.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
@@ -510,6 +514,14 @@ describe("exec exit codes", () => {
|
||||
});
|
||||
|
||||
describe("exec notifyOnExit", () => {
|
||||
beforeEach(() => {
|
||||
resetHeartbeatWakeStateForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHeartbeatWakeStateForTests();
|
||||
});
|
||||
|
||||
it("enqueues a system event when a backgrounded exec exits", async () => {
|
||||
const tool = createNotifyOnExitExecTool();
|
||||
|
||||
@@ -521,6 +533,45 @@ describe("exec notifyOnExit", () => {
|
||||
expect(hasEvent).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes notifyOnExit heartbeat wake to the exec session key", async () => {
|
||||
const tool = createNotifyOnExitExecTool();
|
||||
const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
|
||||
const dispose = setHeartbeatWakeHandler(
|
||||
wakeHandler as unknown as Parameters<typeof setHeartbeatWakeHandler>[0],
|
||||
);
|
||||
try {
|
||||
const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));
|
||||
|
||||
await expect
|
||||
.poll(() => wakeHandler.mock.calls[0]?.[0], NOTIFY_POLL_OPTIONS)
|
||||
.toMatchObject({
|
||||
reason: `exec:${sessionId}:exit`,
|
||||
sessionKey: DEFAULT_NOTIFY_SESSION_KEY,
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps notifyOnExit heartbeat wake unscoped for non-agent session keys", async () => {
|
||||
const tool = createNotifyOnExitExecTool({ sessionKey: "global" });
|
||||
const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });
|
||||
const dispose = setHeartbeatWakeHandler(
|
||||
wakeHandler as unknown as Parameters<typeof setHeartbeatWakeHandler>[0],
|
||||
);
|
||||
try {
|
||||
const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));
|
||||
|
||||
await expect
|
||||
.poll(() => wakeHandler.mock.calls[0]?.[0], NOTIFY_POLL_OPTIONS)
|
||||
.toEqual({
|
||||
reason: `exec:${sessionId}:exit`,
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each<NotifyNoopCase>(NOOP_NOTIFY_CASES)("$label", runNotifyNoopCase);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,20 @@ export const MIN_CHUNK_RATIO = 0.15;
|
||||
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
||||
const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
|
||||
const DEFAULT_PARTS = 2;
|
||||
const MERGE_SUMMARIES_INSTRUCTIONS =
|
||||
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
|
||||
" TODOs, open questions, and any constraints.";
|
||||
const MERGE_SUMMARIES_INSTRUCTIONS = [
|
||||
"Merge these partial summaries into a single cohesive summary.",
|
||||
"",
|
||||
"MUST PRESERVE:",
|
||||
"- Active tasks and their current status (in-progress, blocked, pending)",
|
||||
"- Batch operation progress (e.g., '5/17 items completed')",
|
||||
"- The last thing the user requested and what was being done about it",
|
||||
"- Decisions made and their rationale",
|
||||
"- TODOs, open questions, and constraints",
|
||||
"- Any commitments or follow-ups promised",
|
||||
"",
|
||||
"PRIORITIZE recent context over older history. The agent needs to know",
|
||||
"what it was doing, not just what was discussed.",
|
||||
].join("\n");
|
||||
const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
|
||||
"Preserve all opaque identifiers exactly as written (no shortening or reconstruction), " +
|
||||
"including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.";
|
||||
|
||||
@@ -53,7 +53,6 @@ import { detectRuntimeShell } from "../shell-utils.js";
|
||||
import {
|
||||
applySkillEnvOverrides,
|
||||
applySkillEnvOverridesFromSnapshot,
|
||||
loadWorkspaceSkillEntries,
|
||||
resolveSkillsPromptForRun,
|
||||
type SkillSnapshot,
|
||||
} from "../skills.js";
|
||||
@@ -74,6 +73,7 @@ import { log } from "./logger.js";
|
||||
import { buildModelAliasLines, resolveModel } from "./model.js";
|
||||
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
|
||||
import {
|
||||
applySystemPromptOverrideToSession,
|
||||
buildEmbeddedSystemPrompt,
|
||||
@@ -333,10 +333,11 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(effectiveWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
||||
: [];
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
|
||||
@@ -1295,7 +1295,11 @@ export async function runEmbeddedPiAgent(
|
||||
aborted,
|
||||
systemPromptReport: attempt.systemPromptReport,
|
||||
// Handle client tool calls (OpenResponses hosted tools)
|
||||
stopReason: attempt.clientToolCall ? "tool_calls" : undefined,
|
||||
// Propagate the LLM stop reason so callers (lifecycle events,
|
||||
// ACP bridge) can distinguish end_turn from max_tokens.
|
||||
stopReason: attempt.clientToolCall
|
||||
? "tool_calls"
|
||||
: (lastAssistant?.stopReason as string | undefined),
|
||||
pendingToolCalls: attempt.clientToolCall
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -69,7 +69,6 @@ import { detectRuntimeShell } from "../../shell-utils.js";
|
||||
import {
|
||||
applySkillEnvOverrides,
|
||||
applySkillEnvOverridesFromSnapshot,
|
||||
loadWorkspaceSkillEntries,
|
||||
resolveSkillsPromptForRun,
|
||||
} from "../../skills.js";
|
||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
@@ -99,6 +98,7 @@ import {
|
||||
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
|
||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../skills-runtime.js";
|
||||
import {
|
||||
applySystemPromptOverrideToSession,
|
||||
buildEmbeddedSystemPrompt,
|
||||
@@ -570,10 +570,11 @@ export async function runEmbeddedAttempt(
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(effectiveWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
||||
: [];
|
||||
const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function setupBundledDiffsPlugin() {
|
||||
const bundledPluginsDir = await createTempDir("openclaw-bundled-");
|
||||
const workspaceDir = await createTempDir("openclaw-workspace-");
|
||||
const pluginRoot = path.join(bundledPluginsDir, "diffs");
|
||||
|
||||
await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "diffs",
|
||||
skills: ["./skills"],
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "skills", "diffs", "SKILL.md"),
|
||||
`---\nname: diffs\ndescription: runtime integration test\n---\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return { bundledPluginsDir, workspaceDir };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir;
|
||||
clearPluginManifestRegistryCache();
|
||||
await Promise.all(
|
||||
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveEmbeddedRunSkillEntries (integration)", () => {
|
||||
it("loads bundled diffs skill when explicitly enabled in config", async () => {
|
||||
const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin();
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir;
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(result.shouldLoadSkillEntries).toBe(true);
|
||||
expect(result.skillEntries.map((entry) => entry.skill.name)).toContain("diffs");
|
||||
});
|
||||
|
||||
it("skips bundled diffs skill when config is missing", async () => {
|
||||
const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin();
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir;
|
||||
clearPluginManifestRegistryCache();
|
||||
|
||||
const result = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(result.shouldLoadSkillEntries).toBe(true);
|
||||
expect(result.skillEntries.map((entry) => entry.skill.name)).not.toContain("diffs");
|
||||
});
|
||||
});
|
||||
70
src/agents/pi-embedded-runner/skills-runtime.test.ts
Normal file
70
src/agents/pi-embedded-runner/skills-runtime.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SkillSnapshot } from "../skills.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
loadWorkspaceSkillEntries: vi.fn(
|
||||
(_workspaceDir: string, _options?: { config?: OpenClawConfig }) => [],
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../skills.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../skills.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadWorkspaceSkillEntries: (workspaceDir: string, options?: { config?: OpenClawConfig }) =>
|
||||
hoisted.loadWorkspaceSkillEntries(workspaceDir, options),
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveEmbeddedRunSkillEntries } = await import("./skills-runtime.js");
|
||||
|
||||
describe("resolveEmbeddedRunSkillEntries", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.loadWorkspaceSkillEntries.mockReset();
|
||||
hoisted.loadWorkspaceSkillEntries.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it("loads skill entries with config when no resolved snapshot skills exist", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config,
|
||||
skillsSnapshot: {
|
||||
prompt: "skills prompt",
|
||||
skills: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.shouldLoadSkillEntries).toBe(true);
|
||||
expect(hoisted.loadWorkspaceSkillEntries).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.loadWorkspaceSkillEntries).toHaveBeenCalledWith("/tmp/workspace", { config });
|
||||
});
|
||||
|
||||
it("skips skill entry loading when resolved snapshot skills are present", () => {
|
||||
const snapshot: SkillSnapshot = {
|
||||
prompt: "skills prompt",
|
||||
skills: [{ name: "diffs" }],
|
||||
resolvedSkills: [],
|
||||
};
|
||||
|
||||
const result = resolveEmbeddedRunSkillEntries({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {},
|
||||
skillsSnapshot: snapshot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldLoadSkillEntries: false,
|
||||
skillEntries: [],
|
||||
});
|
||||
expect(hoisted.loadWorkspaceSkillEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
19
src/agents/pi-embedded-runner/skills-runtime.ts
Normal file
19
src/agents/pi-embedded-runner/skills-runtime.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadWorkspaceSkillEntries, type SkillEntry, type SkillSnapshot } from "../skills.js";
|
||||
|
||||
export function resolveEmbeddedRunSkillEntries(params: {
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
}): {
|
||||
shouldLoadSkillEntries: boolean;
|
||||
skillEntries: SkillEntry[];
|
||||
} {
|
||||
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
return {
|
||||
shouldLoadSkillEntries,
|
||||
skillEntries: shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(params.workspaceDir, { config: params.config })
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,36 @@ async function setupWorkspaceWithProsePlugin() {
|
||||
return { workspaceDir, managedDir, bundledDir };
|
||||
}
|
||||
|
||||
async function setupWorkspaceWithDiffsPlugin() {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
const managedDir = path.join(workspaceDir, ".managed");
|
||||
const bundledDir = path.join(workspaceDir, ".bundled");
|
||||
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "diffs");
|
||||
|
||||
await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "diffs",
|
||||
skills: ["./skills"],
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "skills", "diffs", "SKILL.md"),
|
||||
`---\nname: diffs\ndescription: test\n---\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return { workspaceDir, managedDir, bundledDir };
|
||||
}
|
||||
|
||||
describe("loadWorkspaceSkillEntries", () => {
|
||||
it("handles an empty managed skills dir without throwing", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
@@ -93,4 +123,36 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("prose");
|
||||
});
|
||||
|
||||
it("includes diffs plugin skill when the plugin is enabled", async () => {
|
||||
const { workspaceDir, managedDir, bundledDir } = await setupWorkspaceWithDiffsPlugin();
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: { diffs: { enabled: true } },
|
||||
},
|
||||
},
|
||||
managedSkillsDir: managedDir,
|
||||
bundledSkillsDir: bundledDir,
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toContain("diffs");
|
||||
});
|
||||
|
||||
it("excludes diffs plugin skill when the plugin is disabled", async () => {
|
||||
const { workspaceDir, managedDir, bundledDir } = await setupWorkspaceWithDiffsPlugin();
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: { diffs: { enabled: false } },
|
||||
},
|
||||
},
|
||||
managedSkillsDir: managedDir,
|
||||
bundledSkillsDir: bundledDir,
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("diffs");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,14 +118,18 @@ vi.mock("./tools/agent-step.js", () => ({
|
||||
readLatestAssistantReply: readLatestAssistantReplyMock,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
loadSessionStore: vi.fn(() => loadSessionStoreFixture()),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: vi.fn(() => loadSessionStoreFixture()),
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./pi-embedded.js", () => embeddedRunMock);
|
||||
|
||||
|
||||
@@ -90,77 +90,118 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
});
|
||||
|
||||
it("fails fast when gateway-backed resolution is unavailable", async () => {
|
||||
const envKey = "TALK_API_KEY_FAILFAST";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i);
|
||||
try {
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => {
|
||||
const priorValue = process.env.TALK_API_KEY;
|
||||
process.env.TALK_API_KEY = "local-fallback-key";
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
});
|
||||
delete process.env.TALK_API_KEY;
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/does not support secrets\.resolve/i);
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env.TALK_API_KEY;
|
||||
} else {
|
||||
process.env.TALK_API_KEY = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
|
||||
const envKey = "TALK_API_KEY_UNSUPPORTED";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));
|
||||
try {
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/does not support secrets\.resolve/i);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a version-skew hint when required-method capability check fails", async () => {
|
||||
const envKey = "TALK_API_KEY_REQUIRED_METHOD";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
callGateway.mockRejectedValueOnce(
|
||||
new Error(
|
||||
'active gateway does not support required method "secrets.resolve" for "secrets.resolve".',
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/does not support secrets\.resolve/i);
|
||||
try {
|
||||
await expect(
|
||||
resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "memory status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
}),
|
||||
).rejects.toThrow(/does not support secrets\.resolve/i);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fails when gateway returns an invalid secrets.resolve payload", async () => {
|
||||
|
||||
@@ -873,6 +873,10 @@ async function agentCommandInternal(
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
if (!lifecycleEnded) {
|
||||
const stopReason = result.meta.stopReason;
|
||||
if (stopReason && stopReason !== "end_turn") {
|
||||
console.error(`[agent] run ${runId} ended with stopReason=${stopReason}`);
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
@@ -881,6 +885,7 @@ async function agentCommandInternal(
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
aborted: result.meta.aborted ?? false,
|
||||
stopReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
@@ -23,6 +23,10 @@ import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
const noteSpy = vi.mocked(note);
|
||||
|
||||
describe("doctor missing default account binding warning", () => {
|
||||
beforeEach(() => {
|
||||
noteSpy.mockClear();
|
||||
});
|
||||
|
||||
it("emits a doctor warning when named accounts have no valid account-scoped bindings", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
@@ -52,4 +56,67 @@ describe("doctor missing default account binding warning", () => {
|
||||
"Doctor warnings",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a warning when multiple accounts have no explicit default", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
TELEGRAM_BOT_TOKEN: undefined,
|
||||
TELEGRAM_BOT_TOKEN_FILE: undefined,
|
||||
},
|
||||
async () => {
|
||||
await runDoctorConfigWithInput({
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
alerts: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"channels.telegram: multiple accounts are configured but no explicit default is set",
|
||||
),
|
||||
"Doctor warnings",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a warning when defaultAccount does not match configured accounts", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
TELEGRAM_BOT_TOKEN: undefined,
|
||||
TELEGRAM_BOT_TOKEN_FILE: undefined,
|
||||
},
|
||||
async () => {
|
||||
await runDoctorConfigWithInput({
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
alerts: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'channels.telegram: defaultAccount is set to "missing" but does not match configured accounts',
|
||||
),
|
||||
"Doctor warnings",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectMissingExplicitDefaultAccountWarnings } from "./doctor-config-flow.js";
|
||||
|
||||
describe("collectMissingExplicitDefaultAccountWarnings", () => {
|
||||
it("warns when multiple named accounts are configured without default selection", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
alerts: { botToken: "a" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnings = collectMissingExplicitDefaultAccountWarnings(cfg);
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining("channels.telegram: multiple accounts are configured"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not warn for a single named account without default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn when accounts.default exists", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
default: { botToken: "d" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not warn when defaultAccount points to a configured account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
alerts: { botToken: "a" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes defaultAccount before validating configured account ids", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "Router D",
|
||||
accounts: {
|
||||
"router-d": { botToken: "r" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when defaultAccount is invalid for configured accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
alerts: { botToken: "a" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnings = collectMissingExplicitDefaultAccountWarnings(cfg);
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('channels.telegram: defaultAccount is set to "missing"'),
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns across channels that support account maps", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
alerts: { botToken: "a" },
|
||||
work: { botToken: "w" },
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
accounts: {
|
||||
a: { botToken: "x" },
|
||||
b: { botToken: "y" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const warnings = collectMissingExplicitDefaultAccountWarnings(cfg);
|
||||
expect(warnings).toHaveLength(2);
|
||||
expect(warnings.some((line) => line.includes("channels.telegram"))).toBe(true);
|
||||
expect(warnings.some((line) => line.includes("channels.slack"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,16 @@ import {
|
||||
normalizeTrustedSafeBinDirs,
|
||||
} from "../infra/exec-safe-bin-trust.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
formatChannelAccountsDefaultPath,
|
||||
formatSetExplicitDefaultInstruction,
|
||||
formatSetExplicitDefaultToConfiguredInstruction,
|
||||
} from "../routing/default-account-warnings.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
isDiscordMutableAllowEntry,
|
||||
isGoogleChatMutableAllowEntry,
|
||||
@@ -215,15 +224,21 @@ function normalizeBindingChannelKey(raw?: string | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
|
||||
type ChannelMissingDefaultAccountContext = {
|
||||
channelKey: string;
|
||||
channel: Record<string, unknown>;
|
||||
normalizedAccountIds: string[];
|
||||
};
|
||||
|
||||
function collectChannelsMissingDefaultAccount(
|
||||
cfg: OpenClawConfig,
|
||||
): ChannelMissingDefaultAccountContext[] {
|
||||
const channels = asObjectRecord(cfg.channels);
|
||||
if (!channels) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const contexts: ChannelMissingDefaultAccountContext[] = [];
|
||||
for (const [channelKey, rawChannel] of Object.entries(channels)) {
|
||||
const channel = asObjectRecord(rawChannel);
|
||||
if (!channel) {
|
||||
@@ -240,10 +255,20 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig)
|
||||
.map((accountId) => normalizeAccountId(accountId))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
).toSorted((a, b) => a.localeCompare(b));
|
||||
if (normalizedAccountIds.length === 0 || normalizedAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
continue;
|
||||
}
|
||||
contexts.push({ channelKey, channel, normalizedAccountIds });
|
||||
}
|
||||
return contexts;
|
||||
}
|
||||
|
||||
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
|
||||
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const { channelKey, normalizedAccountIds } of collectChannelsMissingDefaultAccount(cfg)) {
|
||||
const accountIdSet = new Set(normalizedAccountIds);
|
||||
const channelPattern = normalizeBindingChannelKey(channelKey);
|
||||
|
||||
@@ -291,13 +316,43 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig)
|
||||
}
|
||||
if (coveredAccountIds.size > 0) {
|
||||
warnings.push(
|
||||
`- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add channels.${channelKey}.accounts.default.`,
|
||||
`- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add ${formatChannelAccountsDefaultPath(channelKey)}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add channels.${channelKey}.accounts.default.`,
|
||||
`- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add ${formatChannelAccountsDefaultPath(channelKey)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function collectMissingExplicitDefaultAccountWarnings(cfg: OpenClawConfig): string[] {
|
||||
const warnings: string[] = [];
|
||||
for (const { channelKey, channel, normalizedAccountIds } of collectChannelsMissingDefaultAccount(
|
||||
cfg,
|
||||
)) {
|
||||
if (normalizedAccountIds.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
if (preferredDefault) {
|
||||
if (normalizedAccountIds.includes(preferredDefault)) {
|
||||
continue;
|
||||
}
|
||||
warnings.push(
|
||||
`- channels.${channelKey}: defaultAccount is set to "${preferredDefault}" but does not match configured accounts (${normalizedAccountIds.join(", ")}). ${formatSetExplicitDefaultToConfiguredInstruction({ channelKey })} to avoid fallback routing.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`- channels.${channelKey}: multiple accounts are configured but no explicit default is set. ${formatSetExplicitDefaultInstruction(channelKey)} to avoid fallback routing.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1812,6 +1867,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
if (missingDefaultAccountBindingWarnings.length > 0) {
|
||||
note(missingDefaultAccountBindingWarnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
const missingExplicitDefaultWarnings = collectMissingExplicitDefaultAccountWarnings(candidate);
|
||||
if (missingExplicitDefaultWarnings.length > 0) {
|
||||
note(missingExplicitDefaultWarnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
if (shouldRepair) {
|
||||
const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
saveAuthProfileStore,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
@@ -49,6 +50,10 @@ const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_R
|
||||
const GATEWAY_LIVE_DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const GATEWAY_LIVE_UNBOUNDED_TIMEOUT_MS = 60 * 60 * 1000;
|
||||
const GATEWAY_LIVE_MAX_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
||||
const GATEWAY_LIVE_PROBE_TIMEOUT_MS = Math.max(
|
||||
30_000,
|
||||
toInt(process.env.OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS, 90_000),
|
||||
);
|
||||
const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels();
|
||||
const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS);
|
||||
|
||||
@@ -96,6 +101,28 @@ function resolveGatewayLiveSuiteTimeoutMs(maxModels: number): number {
|
||||
);
|
||||
}
|
||||
|
||||
function isGatewayLiveProbeTimeout(error: string): boolean {
|
||||
return /probe timeout after \d+ms/i.test(error);
|
||||
}
|
||||
|
||||
async function withGatewayLiveProbeTimeout<T>(operation: Promise<T>, context: string): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`probe timeout after ${GATEWAY_LIVE_PROBE_TIMEOUT_MS}ms (${context})`));
|
||||
}, GATEWAY_LIVE_PROBE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capByProviderSpread<T>(
|
||||
items: T[],
|
||||
maxItems: number,
|
||||
@@ -264,6 +291,11 @@ function isToolNonceRefusal(error: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isToolNonceProbeMiss(error: string): boolean {
|
||||
const msg = error.toLowerCase();
|
||||
return msg.includes("tool probe missing nonce") || msg.includes("exec+read probe missing nonce");
|
||||
}
|
||||
|
||||
function isMissingProfileError(error: string): boolean {
|
||||
return /no credentials found for profile/i.test(error);
|
||||
}
|
||||
@@ -287,16 +319,19 @@ async function runAnthropicRefusalProbe(params: {
|
||||
logProgress(`${params.label}: refusal-probe`);
|
||||
const magic = buildAnthropicRefusalToken();
|
||||
const runId = randomUUID();
|
||||
const probe = await params.client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey: params.sessionKey,
|
||||
idempotencyKey: `idem-${runId}-refusal`,
|
||||
message: `Reply with the single word ok. Test token: ${magic}`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const probe = await withGatewayLiveProbeTimeout(
|
||||
params.client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey: params.sessionKey,
|
||||
idempotencyKey: `idem-${runId}-refusal`,
|
||||
message: `Reply with the single word ok. Test token: ${magic}`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${params.label}: refusal-probe`,
|
||||
);
|
||||
if (probe?.status !== "ok") {
|
||||
throw new Error(`refusal probe failed: status=${String(probe?.status)}`);
|
||||
@@ -313,16 +348,19 @@ async function runAnthropicRefusalProbe(params: {
|
||||
}
|
||||
|
||||
const followupId = randomUUID();
|
||||
const followup = await params.client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey: params.sessionKey,
|
||||
idempotencyKey: `idem-${followupId}-refusal-followup`,
|
||||
message: "Now reply with exactly: still ok.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const followup = await withGatewayLiveProbeTimeout(
|
||||
params.client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey: params.sessionKey,
|
||||
idempotencyKey: `idem-${followupId}-refusal-followup`,
|
||||
message: "Now reply with exactly: still ok.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${params.label}: refusal-followup`,
|
||||
);
|
||||
if (followup?.status !== "ok") {
|
||||
throw new Error(`refusal followup failed: status=${String(followup?.status)}`);
|
||||
@@ -666,19 +704,49 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
|
||||
process.env.OPENCLAW_CONFIG_PATH = tempConfigPath;
|
||||
|
||||
await ensureOpenClawModelsJson(nextCfg);
|
||||
const liveProviders = nextCfg.models?.providers;
|
||||
if (liveProviders && Object.keys(liveProviders).length > 0) {
|
||||
const modelsPath = path.join(tempAgentDir, "models.json");
|
||||
await fs.mkdir(tempAgentDir, { recursive: true });
|
||||
await fs.writeFile(modelsPath, `${JSON.stringify({ providers: liveProviders }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
let client: GatewayClient | undefined;
|
||||
try {
|
||||
const port = await withGatewayLiveProbeTimeout(
|
||||
getFreeGatewayPort(),
|
||||
`${params.label}: gateway-port`,
|
||||
);
|
||||
server = await withGatewayLiveProbeTimeout(
|
||||
startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
}),
|
||||
`${params.label}: gateway-start`,
|
||||
);
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
client = await withGatewayLiveProbeTimeout(
|
||||
connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
}),
|
||||
`${params.label}: gateway-connect`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
if (isGatewayLiveProbeTimeout(message)) {
|
||||
logProgress(`[${params.label}] skip (gateway startup timeout)`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!server || !client) {
|
||||
logProgress(`[${params.label}] skip (gateway startup incomplete)`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logProgress(
|
||||
@@ -709,27 +777,36 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
// Ensure session exists + override model for this run.
|
||||
// Reset between models: avoids cross-provider transcript incompatibilities
|
||||
// (notably OpenAI Responses requiring reasoning replay for function_call items).
|
||||
await client.request("sessions.reset", {
|
||||
key: sessionKey,
|
||||
});
|
||||
await client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: modelKey,
|
||||
});
|
||||
await withGatewayLiveProbeTimeout(
|
||||
client.request("sessions.reset", {
|
||||
key: sessionKey,
|
||||
}),
|
||||
`${progressLabel}: sessions-reset`,
|
||||
);
|
||||
await withGatewayLiveProbeTimeout(
|
||||
client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: modelKey,
|
||||
}),
|
||||
`${progressLabel}: sessions-patch`,
|
||||
);
|
||||
|
||||
logProgress(`${progressLabel}: prompt`);
|
||||
const runId = randomUUID();
|
||||
const payload = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const payload = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: prompt`,
|
||||
);
|
||||
|
||||
if (payload?.status !== "ok") {
|
||||
@@ -738,17 +815,20 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
let text = extractPayloadText(payload?.result);
|
||||
if (!text) {
|
||||
logProgress(`${progressLabel}: empty response, retrying`);
|
||||
const retry = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${randomUUID()}-retry`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const retry = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${randomUUID()}-retry`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: prompt-retry`,
|
||||
);
|
||||
if (retry?.status !== "ok") {
|
||||
throw new Error(`agent status=${String(retry?.status)}`);
|
||||
@@ -800,22 +880,25 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
toolReadAttempt += 1
|
||||
) {
|
||||
const strictReply = toolReadAttempt > 0;
|
||||
const toolProbe = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`,
|
||||
message: strictReply
|
||||
? "OpenClaw live tool probe (local, safe): " +
|
||||
`use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`
|
||||
: "OpenClaw live tool probe (local, safe): " +
|
||||
`use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
"Then reply with the two nonce values you read (include both).",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const toolProbe = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`,
|
||||
message: strictReply
|
||||
? "OpenClaw live tool probe (local, safe): " +
|
||||
`use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`
|
||||
: "OpenClaw live tool probe (local, safe): " +
|
||||
`use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
"Then reply with the two nonce values you read (include both).",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: tool-read`,
|
||||
);
|
||||
if (toolProbe?.status !== "ok") {
|
||||
if (toolReadAttempt + 1 < maxToolReadAttempts) {
|
||||
@@ -876,26 +959,29 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
execReadAttempt += 1
|
||||
) {
|
||||
const strictReply = execReadAttempt > 0;
|
||||
const execReadProbe = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`,
|
||||
message: strictReply
|
||||
? "OpenClaw live tool probe (local, safe): " +
|
||||
"use the tool named `exec` (or `Exec`) to run this command: " +
|
||||
`mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` +
|
||||
`Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceC}. No extra text.`
|
||||
: "OpenClaw live tool probe (local, safe): " +
|
||||
"use the tool named `exec` (or `Exec`) to run this command: " +
|
||||
`mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` +
|
||||
`Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` +
|
||||
"Finally reply including the nonce text you read back.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const execReadProbe = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`,
|
||||
message: strictReply
|
||||
? "OpenClaw live tool probe (local, safe): " +
|
||||
"use the tool named `exec` (or `Exec`) to run this command: " +
|
||||
`mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` +
|
||||
`Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceC}. No extra text.`
|
||||
: "OpenClaw live tool probe (local, safe): " +
|
||||
"use the tool named `exec` (or `Exec`) to run this command: " +
|
||||
`mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` +
|
||||
`Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` +
|
||||
"Finally reply including the nonce text you read back.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: tool-exec`,
|
||||
);
|
||||
if (execReadProbe?.status !== "ok") {
|
||||
if (execReadAttempt + 1 < maxExecReadAttempts) {
|
||||
@@ -952,26 +1038,29 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
const imageBase64 = renderCatNoncePngBase64(imageCode);
|
||||
const runIdImage = randomUUID();
|
||||
|
||||
const imageProbe = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdImage}-image`,
|
||||
message:
|
||||
"Look at the attached image. Reply with exactly two tokens separated by a single space: " +
|
||||
"(1) the animal shown or written in the image, lowercase; " +
|
||||
"(2) the code printed in the image, uppercase. No extra text.",
|
||||
attachments: [
|
||||
{
|
||||
mimeType: "image/png",
|
||||
fileName: `probe-${runIdImage}.png`,
|
||||
content: imageBase64,
|
||||
},
|
||||
],
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const imageProbe = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runIdImage}-image`,
|
||||
message:
|
||||
"Look at the attached image. Reply with exactly two tokens separated by a single space: " +
|
||||
"(1) the animal shown or written in the image, lowercase; " +
|
||||
"(2) the code printed in the image, uppercase. No extra text.",
|
||||
attachments: [
|
||||
{
|
||||
mimeType: "image/png",
|
||||
fileName: `probe-${runIdImage}.png`,
|
||||
content: imageBase64,
|
||||
},
|
||||
],
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: image`,
|
||||
);
|
||||
// Best-effort: do not fail the whole live suite on flaky image handling.
|
||||
// (We still keep prompt + tool probes as hard checks.)
|
||||
@@ -1017,16 +1106,19 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
) {
|
||||
logProgress(`${progressLabel}: tool-only regression`);
|
||||
const runId2 = randomUUID();
|
||||
const first = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-1`,
|
||||
message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const first = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-1`,
|
||||
message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: tool-only-regression-first`,
|
||||
);
|
||||
if (first?.status !== "ok") {
|
||||
throw new Error(`tool-only turn failed: status=${String(first?.status)}`);
|
||||
@@ -1039,16 +1131,19 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
label: params.label,
|
||||
});
|
||||
|
||||
const second = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-2`,
|
||||
message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const second = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId2}-2`,
|
||||
message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`,
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
`${progressLabel}: tool-only-regression-second`,
|
||||
);
|
||||
if (second?.status !== "ok") {
|
||||
throw new Error(`post-tool message failed: status=${String(second?.status)}`);
|
||||
@@ -1118,6 +1213,19 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (provider unavailable)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
model.provider === "anthropic" &&
|
||||
isGatewayLiveProbeTimeout(message) &&
|
||||
attempt + 1 < attemptMax
|
||||
) {
|
||||
logProgress(`${progressLabel}: probe timeout, retrying with next key`);
|
||||
continue;
|
||||
}
|
||||
if (isGatewayLiveProbeTimeout(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (probe timeout)`);
|
||||
break;
|
||||
}
|
||||
// OpenAI Codex refresh tokens can become single-use; skip instead of failing all live tests.
|
||||
if (model.provider === "openai-codex" && isRefreshTokenReused(message)) {
|
||||
logProgress(`${progressLabel}: skip (codex refresh token reused)`);
|
||||
@@ -1148,6 +1256,11 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (tool probe refusal)`);
|
||||
break;
|
||||
}
|
||||
if (model.provider === "anthropic" && isToolNonceProbeMiss(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (anthropic tool probe nonce miss)`);
|
||||
break;
|
||||
}
|
||||
if (isMissingProfileError(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (missing auth profile)`);
|
||||
@@ -1222,26 +1335,26 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
? all.filter((m) => filter.has(`${m.provider}/${m.id}`))
|
||||
: all.filter((m) => isModernModelRef({ provider: m.provider, id: m.id }));
|
||||
|
||||
const providerProfileCache = new Map<string, boolean>();
|
||||
const candidates: Array<Model<Api>> = [];
|
||||
for (const model of wanted) {
|
||||
if (PROVIDERS && !PROVIDERS.has(model.provider)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
let hasProfile = providerProfileCache.get(model.provider);
|
||||
if (hasProfile === undefined) {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store: authStore,
|
||||
agentDir,
|
||||
provider: model.provider,
|
||||
});
|
||||
if (!apiKeyInfo.source.startsWith("profile:")) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(model);
|
||||
} catch {
|
||||
// no creds; skip
|
||||
hasProfile = order.some((profileId) => Boolean(authStore.profiles[profileId]));
|
||||
providerProfileCache.set(model.provider, hasProfile);
|
||||
}
|
||||
if (!hasProfile) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(model);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
@@ -1348,42 +1461,76 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
const toolProbePath = path.join(workspaceDir, `.openclaw-live-zai-fallback.${nonceA}.txt`);
|
||||
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
let client: GatewayClient | undefined;
|
||||
try {
|
||||
const port = await withGatewayLiveProbeTimeout(
|
||||
getFreeGatewayPort(),
|
||||
"zai-fallback: gateway-port",
|
||||
);
|
||||
server = await withGatewayLiveProbeTimeout(
|
||||
startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
}),
|
||||
"zai-fallback: gateway-start",
|
||||
);
|
||||
|
||||
const client = await connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
});
|
||||
client = await withGatewayLiveProbeTimeout(
|
||||
connectClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token,
|
||||
}),
|
||||
"zai-fallback: gateway-connect",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
if (isGatewayLiveProbeTimeout(message)) {
|
||||
logProgress("[zai-fallback] skip (gateway startup timeout)");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!server || !client) {
|
||||
logProgress("[zai-fallback] skip (gateway startup incomplete)");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionKey = `agent:${agentId}:live-zai-fallback`;
|
||||
|
||||
await client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
await client.request("sessions.reset", {
|
||||
key: sessionKey,
|
||||
});
|
||||
await withGatewayLiveProbeTimeout(
|
||||
client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
}),
|
||||
"zai-fallback: sessions-patch-anthropic",
|
||||
);
|
||||
await withGatewayLiveProbeTimeout(
|
||||
client.request("sessions.reset", {
|
||||
key: sessionKey,
|
||||
}),
|
||||
"zai-fallback: sessions-reset",
|
||||
);
|
||||
|
||||
const runId = randomUUID();
|
||||
const toolProbe = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}-tool`,
|
||||
message:
|
||||
`Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
|
||||
thinking: THINKING_LEVEL,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const toolProbe = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${runId}-tool`,
|
||||
message:
|
||||
`Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` +
|
||||
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
|
||||
thinking: THINKING_LEVEL,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
"zai-fallback: tool-probe",
|
||||
);
|
||||
if (toolProbe?.status !== "ok") {
|
||||
throw new Error(`anthropic tool probe failed: status=${String(toolProbe?.status)}`);
|
||||
@@ -1399,24 +1546,30 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
throw new Error(`anthropic tool probe missing nonce: ${toolText}`);
|
||||
}
|
||||
|
||||
await client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "zai/glm-4.7",
|
||||
});
|
||||
await withGatewayLiveProbeTimeout(
|
||||
client.request("sessions.patch", {
|
||||
key: sessionKey,
|
||||
model: "zai/glm-4.7",
|
||||
}),
|
||||
"zai-fallback: sessions-patch-zai",
|
||||
);
|
||||
|
||||
const followupId = randomUUID();
|
||||
const followup = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${followupId}-followup`,
|
||||
message:
|
||||
`What are the values of nonceA and nonceB in "${toolProbePath}"? ` +
|
||||
`Reply with exactly: ${nonceA} ${nonceB}.`,
|
||||
thinking: THINKING_LEVEL,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
const followup = await withGatewayLiveProbeTimeout(
|
||||
client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${followupId}-followup`,
|
||||
message:
|
||||
`What are the values of nonceA and nonceB in "${toolProbePath}"? ` +
|
||||
`Reply with exactly: ${nonceA} ${nonceB}.`,
|
||||
thinking: THINKING_LEVEL,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
),
|
||||
"zai-fallback: followup",
|
||||
);
|
||||
if (followup?.status !== "ok") {
|
||||
throw new Error(`zai followup failed: status=${String(followup?.status)}`);
|
||||
|
||||
@@ -266,6 +266,89 @@ describe("agent event handler", () => {
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes buffered text as delta before final when throttle suppresses the latest chunk", () => {
|
||||
let now = 10_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness();
|
||||
chatRunState.registry.add("run-flush", {
|
||||
sessionKey: "session-flush",
|
||||
clientRunId: "client-flush",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-flush",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "Hello" },
|
||||
});
|
||||
|
||||
now = 10_100;
|
||||
handler({
|
||||
runId: "run-flush",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "Hello world" },
|
||||
});
|
||||
|
||||
emitLifecycleEnd(handler, "run-flush");
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(3);
|
||||
const firstPayload = chatCalls[0]?.[1] as { state?: string };
|
||||
const secondPayload = chatCalls[1]?.[1] as {
|
||||
state?: string;
|
||||
message?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const thirdPayload = chatCalls[2]?.[1] as { state?: string };
|
||||
expect(firstPayload.state).toBe("delta");
|
||||
expect(secondPayload.state).toBe("delta");
|
||||
expect(secondPayload.message?.content?.[0]?.text).toBe("Hello world");
|
||||
expect(thirdPayload.state).toBe("final");
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not flush an extra delta when the latest text already broadcast", () => {
|
||||
let now = 11_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness();
|
||||
chatRunState.registry.add("run-no-dup-flush", {
|
||||
sessionKey: "session-no-dup-flush",
|
||||
clientRunId: "client-no-dup-flush",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-no-dup-flush",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "Hello" },
|
||||
});
|
||||
|
||||
now = 11_200;
|
||||
handler({
|
||||
runId: "run-no-dup-flush",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "Hello world" },
|
||||
});
|
||||
|
||||
emitLifecycleEnd(handler, "run-no-dup-flush");
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(3);
|
||||
expect(chatCalls.map(([, payload]) => (payload as { state?: string }).state)).toEqual([
|
||||
"delta",
|
||||
"delta",
|
||||
"final",
|
||||
]);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(3);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("cleans up agent run sequence tracking when lifecycle completes", () => {
|
||||
const { agentRunSeq, chatRunState, handler, nowSpy } = createHarness({ now: 2_500 });
|
||||
chatRunState.registry.add("run-cleanup", {
|
||||
|
||||
@@ -158,6 +158,8 @@ export type ChatRunState = {
|
||||
registry: ChatRunRegistry;
|
||||
buffers: Map<string, string>;
|
||||
deltaSentAt: Map<string, number>;
|
||||
/** Length of text at the time of the last broadcast, used to avoid duplicate flushes. */
|
||||
deltaLastBroadcastLen: Map<string, number>;
|
||||
abortedRuns: Map<string, number>;
|
||||
clear: () => void;
|
||||
};
|
||||
@@ -166,12 +168,14 @@ export function createChatRunState(): ChatRunState {
|
||||
const registry = createChatRunRegistry();
|
||||
const buffers = new Map<string, string>();
|
||||
const deltaSentAt = new Map<string, number>();
|
||||
const deltaLastBroadcastLen = new Map<string, number>();
|
||||
const abortedRuns = new Map<string, number>();
|
||||
|
||||
const clear = () => {
|
||||
registry.clear();
|
||||
buffers.clear();
|
||||
deltaSentAt.clear();
|
||||
deltaLastBroadcastLen.clear();
|
||||
abortedRuns.clear();
|
||||
};
|
||||
|
||||
@@ -179,6 +183,7 @@ export function createChatRunState(): ChatRunState {
|
||||
registry,
|
||||
buffers,
|
||||
deltaSentAt,
|
||||
deltaLastBroadcastLen,
|
||||
abortedRuns,
|
||||
clear,
|
||||
};
|
||||
@@ -318,6 +323,7 @@ export function createAgentEventHandler({
|
||||
return;
|
||||
}
|
||||
chatRunState.deltaSentAt.set(clientRunId, now);
|
||||
chatRunState.deltaLastBroadcastLen.set(clientRunId, cleaned.length);
|
||||
const payload = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
@@ -340,6 +346,7 @@ export function createAgentEventHandler({
|
||||
seq: number,
|
||||
jobState: "done" | "error",
|
||||
error?: unknown,
|
||||
stopReason?: string,
|
||||
) => {
|
||||
const bufferedText = stripInlineDirectiveTagsForDisplay(
|
||||
chatRunState.buffers.get(clientRunId) ?? "",
|
||||
@@ -352,6 +359,39 @@ export function createAgentEventHandler({
|
||||
const text = normalizedHeartbeatText.text.trim();
|
||||
const shouldSuppressSilent =
|
||||
normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN);
|
||||
const shouldSuppressSilentLeadFragment = isSilentReplyLeadFragment(text);
|
||||
const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(
|
||||
clientRunId,
|
||||
sourceRunId,
|
||||
);
|
||||
// Flush any throttled delta so streaming clients receive the complete text
|
||||
// before the final event. The 150 ms throttle in emitChatDelta may have
|
||||
// suppressed the most recent chunk, leaving the client with stale text.
|
||||
// Only flush if the buffer has grown since the last broadcast to avoid duplicates.
|
||||
if (
|
||||
text &&
|
||||
!shouldSuppressSilent &&
|
||||
!shouldSuppressSilentLeadFragment &&
|
||||
!shouldSuppressHeartbeatStreaming
|
||||
) {
|
||||
const lastBroadcastLen = chatRunState.deltaLastBroadcastLen.get(clientRunId) ?? 0;
|
||||
if (text.length > lastBroadcastLen) {
|
||||
const flushPayload = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq,
|
||||
state: "delta" as const,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
broadcast("chat", flushPayload, { dropIfSlow: true });
|
||||
nodeSendToSession(sessionKey, "chat", flushPayload);
|
||||
}
|
||||
}
|
||||
chatRunState.deltaLastBroadcastLen.delete(clientRunId);
|
||||
chatRunState.buffers.delete(clientRunId);
|
||||
chatRunState.deltaSentAt.delete(clientRunId);
|
||||
if (jobState === "done") {
|
||||
@@ -360,6 +400,7 @@ export function createAgentEventHandler({
|
||||
sessionKey,
|
||||
seq,
|
||||
state: "final" as const,
|
||||
...(stopReason && { stopReason }),
|
||||
message:
|
||||
text && !shouldSuppressSilent
|
||||
? {
|
||||
@@ -473,6 +514,8 @@ export function createAgentEventHandler({
|
||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text);
|
||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
const evtStopReason =
|
||||
typeof evt.data?.stopReason === "string" ? evt.data.stopReason : undefined;
|
||||
if (chatLink) {
|
||||
const finished = chatRunState.registry.shift(evt.runId);
|
||||
if (!finished) {
|
||||
@@ -486,6 +529,7 @@ export function createAgentEventHandler({
|
||||
evt.seq,
|
||||
lifecyclePhase === "error" ? "error" : "done",
|
||||
evt.data?.error,
|
||||
evtStopReason,
|
||||
);
|
||||
} else {
|
||||
emitChatFinal(
|
||||
@@ -495,6 +539,7 @@ export function createAgentEventHandler({
|
||||
evt.seq,
|
||||
lifecyclePhase === "error" ? "error" : "done",
|
||||
evt.data?.error,
|
||||
evtStopReason,
|
||||
);
|
||||
}
|
||||
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
|
||||
@@ -84,6 +84,63 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
|
||||
["/ready", "ready"],
|
||||
["/readyz", "ready"],
|
||||
]);
|
||||
const MATTERMOST_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
function resolveMattermostSlashCallbackPaths(
|
||||
configSnapshot: ReturnType<typeof loadConfig>,
|
||||
): Set<string> {
|
||||
const callbackPaths = new Set<string>([MATTERMOST_SLASH_CALLBACK_PATH]);
|
||||
const isMattermostCommandCallbackPath = (path: string): boolean =>
|
||||
path === MATTERMOST_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/");
|
||||
|
||||
const normalizeCallbackPath = (value: unknown): string => {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
return MATTERMOST_SLASH_CALLBACK_PATH;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
};
|
||||
|
||||
const tryAddCallbackUrlPath = (rawUrl: unknown) => {
|
||||
if (typeof rawUrl !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pathname = new URL(trimmed).pathname;
|
||||
if (pathname && isMattermostCommandCallbackPath(pathname)) {
|
||||
callbackPaths.add(pathname);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid callback URLs in config and keep default path behavior.
|
||||
}
|
||||
};
|
||||
|
||||
const mmRaw = configSnapshot.channels?.mattermost as Record<string, unknown> | undefined;
|
||||
const addMmCommands = (raw: unknown) => {
|
||||
if (raw == null || typeof raw !== "object") {
|
||||
return;
|
||||
}
|
||||
const commands = raw as Record<string, unknown>;
|
||||
const callbackPath = normalizeCallbackPath(commands.callbackPath);
|
||||
if (isMattermostCommandCallbackPath(callbackPath)) {
|
||||
callbackPaths.add(callbackPath);
|
||||
}
|
||||
tryAddCallbackUrlPath(commands.callbackUrl);
|
||||
};
|
||||
|
||||
addMmCommands(mmRaw?.commands);
|
||||
const accountsRaw = (mmRaw?.accounts ?? {}) as Record<string, unknown>;
|
||||
for (const accountId of Object.keys(accountsRaw)) {
|
||||
const accountCfg = accountsRaw[accountId] as Record<string, unknown> | undefined;
|
||||
addMmCommands(accountCfg?.commands);
|
||||
}
|
||||
|
||||
return callbackPaths;
|
||||
}
|
||||
|
||||
function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean {
|
||||
return (
|
||||
@@ -174,6 +231,7 @@ function buildPluginRequestStages(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
requestPath: string;
|
||||
mattermostSlashCallbackPaths: ReadonlySet<string>;
|
||||
pluginPathContext: PluginRoutePathContext | null;
|
||||
handlePluginRequest?: PluginHttpRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||
@@ -189,6 +247,9 @@ function buildPluginRequestStages(params: {
|
||||
{
|
||||
name: "plugin-auth",
|
||||
run: async () => {
|
||||
if (params.mattermostSlashCallbackPaths.has(params.requestPath)) {
|
||||
return false;
|
||||
}
|
||||
const pathContext =
|
||||
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
|
||||
if (
|
||||
@@ -506,6 +567,7 @@ export function createGatewayHttpServer(opts: {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const mattermostSlashCallbackPaths = resolveMattermostSlashCallbackPaths(configSnapshot);
|
||||
const pluginPathContext = handlePluginRequest
|
||||
? resolvePluginRoutePathContext(requestPath)
|
||||
: null;
|
||||
@@ -595,6 +657,7 @@ export function createGatewayHttpServer(opts: {
|
||||
req,
|
||||
res,
|
||||
requestPath,
|
||||
mattermostSlashCallbackPaths,
|
||||
pluginPathContext,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
|
||||
@@ -111,7 +111,10 @@ describe("node exec events", () => {
|
||||
"Exec started (node=node-1 id=run-1): ls -la",
|
||||
{ sessionKey: "agent:main:main", contextKey: "exec:run-1" },
|
||||
);
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
|
||||
reason: "exec-event",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues exec.finished events with output", async () => {
|
||||
@@ -185,7 +188,10 @@ describe("node exec events", () => {
|
||||
"Exec denied (node=node-3 id=run-3, allowlist-miss): rm -rf /",
|
||||
{ sessionKey: "agent:demo:main", contextKey: "exec:run-3" },
|
||||
);
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
|
||||
reason: "exec-event",
|
||||
sessionKey: "agent:demo:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses exec.started when notifyOnExit is false", async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { buildOutboundSessionContext } from "../infra/outbound/session-context.j
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import { registerApnsToken } from "../infra/push-apns.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { parseMessageWithAttachments } from "./chat-attachments.js";
|
||||
import { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js";
|
||||
@@ -574,7 +574,10 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
}
|
||||
|
||||
enqueueSystemEvent(text, { sessionKey, contextKey: runId ? `exec:${runId}` : "exec" });
|
||||
requestHeartbeatNow({ reason: "exec-event" });
|
||||
// Scope wakes only for canonical agent sessions. Synthetic node-* fallback
|
||||
// keys should keep legacy unscoped behavior so enabled non-main heartbeat
|
||||
// agents still run when no explicit agent session is provided.
|
||||
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
|
||||
return;
|
||||
}
|
||||
case "push.apns.register": {
|
||||
|
||||
@@ -112,7 +112,8 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("connect (req) handshake resolves server version from env precedence", async () => {
|
||||
test("connect (req) handshake resolves server version from runtime precedence", async () => {
|
||||
const { VERSION } = await import("../version.js");
|
||||
for (const testCase of [
|
||||
{
|
||||
env: {
|
||||
@@ -120,7 +121,7 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
OPENCLAW_SERVICE_VERSION: "2.4.6-service",
|
||||
npm_package_version: "1.0.0-package",
|
||||
},
|
||||
expectedVersion: "2.4.6-service",
|
||||
expectedVersion: VERSION,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
@@ -136,7 +137,7 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
OPENCLAW_SERVICE_VERSION: "\t",
|
||||
npm_package_version: "1.0.0-package",
|
||||
},
|
||||
expectedVersion: "1.0.0-package",
|
||||
expectedVersion: VERSION,
|
||||
},
|
||||
]) {
|
||||
await withRuntimeVersionEnv(testCase.env, async () =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
withGatewayServer,
|
||||
withGatewayTempConfig,
|
||||
} from "./server-http.test-harness.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
type PluginRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
|
||||
@@ -216,6 +217,93 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/api/channels/mattermost/command") {
|
||||
res.statusCode = 200;
|
||||
res.end("ok:mm-callback");
|
||||
return true;
|
||||
}
|
||||
if (pathname === "/api/channels/nostr/default/profile") {
|
||||
res.statusCode = 200;
|
||||
res.end("ok:nostr");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: { trustedProxies: [] },
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: { callbackPath: "/api/channels/mattermost/command" },
|
||||
},
|
||||
},
|
||||
},
|
||||
prefix: "openclaw-plugin-http-auth-mm-callback-",
|
||||
run: async () => {
|
||||
const server = createTestGatewayServer({
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: { handlePluginRequest },
|
||||
});
|
||||
|
||||
const slashCallback = await sendRequest(server, {
|
||||
path: "/api/channels/mattermost/command",
|
||||
method: "POST",
|
||||
});
|
||||
expect(slashCallback.res.statusCode).toBe(200);
|
||||
expect(slashCallback.getBody()).toBe("ok:mm-callback");
|
||||
|
||||
const otherChannelUnauthed = await sendRequest(server, {
|
||||
path: "/api/channels/nostr/default/profile",
|
||||
});
|
||||
expect(otherChannelUnauthed.res.statusCode).toBe(401);
|
||||
expect(otherChannelUnauthed.getBody()).toContain("Unauthorized");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not bypass auth when mattermost callbackPath points to non-mattermost channel routes", async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/api/channels/nostr/default/profile") {
|
||||
res.statusCode = 200;
|
||||
res.end("ok:nostr");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: { trustedProxies: [] },
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: { callbackPath: "/api/channels/nostr/default/profile" },
|
||||
},
|
||||
},
|
||||
},
|
||||
prefix: "openclaw-plugin-http-auth-mm-misconfig-",
|
||||
run: async () => {
|
||||
const server = createTestGatewayServer({
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: { handlePluginRequest },
|
||||
});
|
||||
|
||||
const unauthenticated = await sendRequest(server, {
|
||||
path: "/api/channels/nostr/default/profile",
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(unauthenticated.res.statusCode).toBe(401);
|
||||
expect(unauthenticated.getBody()).toContain("Unauthorized");
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
@@ -1032,7 +1032,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
type: "hello-ok",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
server: {
|
||||
version: resolveRuntimeServiceVersion(process.env, "dev"),
|
||||
version: resolveRuntimeServiceVersion(process.env),
|
||||
connId,
|
||||
},
|
||||
features: { methods: gatewayMethods, events },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user