mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
60 Commits
stack/ios-
...
codex/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dee7d0749 | ||
|
|
669d223d35 | ||
|
|
9c8410a602 | ||
|
|
c30387a65b | ||
|
|
f729e0853b | ||
|
|
1c200ca7ae | ||
|
|
6842877b2e | ||
|
|
b10f438221 | ||
|
|
caa748b969 | ||
|
|
b7589b32a8 | ||
|
|
21e8d88c1d | ||
|
|
a4850b1b8f | ||
|
|
4b17d6d882 | ||
|
|
0d97101665 | ||
|
|
d95cf256e7 | ||
|
|
e8cb0484ce | ||
|
|
b1a735829d | ||
|
|
2a733a8444 | ||
|
|
4c6dec84a6 | ||
|
|
a36ccf4156 | ||
|
|
22e33ddda9 | ||
|
|
a8dd9ffea1 | ||
|
|
bcd58c26d3 | ||
|
|
e4b4486a96 | ||
|
|
3ad3a90db3 | ||
|
|
b02a07655d | ||
|
|
a9969e641a | ||
|
|
ff96e41c38 | ||
|
|
44162e7ba5 | ||
|
|
2cd3be896d | ||
|
|
490670128b | ||
|
|
70c6bc8581 | ||
|
|
65816657c2 | ||
|
|
b0bcea03db | ||
|
|
16ebbd24b5 | ||
|
|
b8b1eeb052 | ||
|
|
0eef7a367d | ||
|
|
548b15d8e0 | ||
|
|
05446d6b6b | ||
|
|
e28ff1215c | ||
|
|
3d998828b9 | ||
|
|
a7a9a3d3c8 | ||
|
|
bf7061092a | ||
|
|
d493861c16 | ||
|
|
a3112d6c5f | ||
|
|
6df57d9633 | ||
|
|
3ee8528b17 | ||
|
|
3b3738e41e | ||
|
|
66d06beec6 | ||
|
|
5d16d45b20 | ||
|
|
6593a57607 | ||
|
|
ec0eb9f8c3 | ||
|
|
606cd0d591 | ||
|
|
d89e1e40f9 | ||
|
|
ca307c3fdf | ||
|
|
4abf398a17 | ||
|
|
8e2e4b2ed5 | ||
|
|
c8b45a4c5c | ||
|
|
171f305c3d | ||
|
|
53727c72f4 |
@@ -5,6 +5,7 @@
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
@@ -75,6 +76,8 @@
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -6,17 +6,57 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Discord/allowBots mention gating: add `allowBots: "mentions"` to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.
|
||||
- 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.
|
||||
- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
|
||||
- Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
|
||||
- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
|
||||
- Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
|
||||
- Plugins/SDK subpath parity: add channel-specific plugin SDK subpaths for Discord, Slack, Signal, iMessage, WhatsApp, and LINE; migrate bundled plugin entrypoints to scoped subpaths/core with CI guardrails; and keep `openclaw/plugin-sdk` root import compatibility for existing external plugins. (#33737) thanks @gumadeiras.
|
||||
- Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n.
|
||||
- Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
|
||||
- Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
|
||||
- Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
|
||||
- Docs/security threat-model links: replace relative `.md` links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.
|
||||
- iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
|
||||
- iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
|
||||
- iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
|
||||
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
|
||||
- Gateway/session agent discovery: include disk-scanned agent IDs in `listConfiguredAgentIds` even when `agents.list` is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.
|
||||
- Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
|
||||
- Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
|
||||
- Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
|
||||
- Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.
|
||||
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
|
||||
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
|
||||
- Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.
|
||||
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
|
||||
- Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.
|
||||
- Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.
|
||||
- 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.
|
||||
- Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
|
||||
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
|
||||
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
|
||||
- Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
|
||||
- Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.
|
||||
- Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.
|
||||
- Telegram/device pairing notifications: auto-arm one-shot notify on `/pair qr`, auto-ping on new pairing requests, and add manual fallback via `/pair approve latest` if the ping does not arrive. (#33299) thanks @mbelinky.
|
||||
- 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.
|
||||
- iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.
|
||||
- iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.
|
||||
- iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.
|
||||
- iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.
|
||||
- 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/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, `/context`, and `openclaw doctor`; add `agents.defaults.bootstrapPromptTruncationWarning` (`off|once|always`, default `once`) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.
|
||||
- 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/Session startup date grounding: substitute `YYYY-MM-DD` placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for `/new` and `/reset` prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.
|
||||
- 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.
|
||||
@@ -121,6 +161,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Discord/acp inline actions: prefer autocomplete for `/acp` action inline values and ignore bound-thread bot system messages to prevent ACP loops. (#33136) Thanks @thewilloftheshadow.
|
||||
- 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.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
@@ -357,6 +398,7 @@ Docs: https://docs.openclaw.ai
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Agents/Compaction safeguard: preserve recent turns verbatim with stable user/assistant pairing, keep multimodal and tool-result hints in preserved tails, and avoid empty-history fallback text in compacted output. (#25554) thanks @rodrigouroz.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
@@ -365,8 +407,13 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs/Contributing: require before/after screenshots for UI or visual PRs in the pre-PR checklist. (#32206) Thanks @hydro13.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Logging/Subsystem console timestamps: route subsystem console timestamp rendering through `formatConsoleTimestamp(...)` so `pretty` and timestamp-prefix output use local timezone formatting consistently instead of inline UTC `toISOString()` paths. (#25970) Thanks @openperf.
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
- Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42.
|
||||
- Channels/Multi-account default routing: add optional `channels.<channel>.defaultAccount` default-selection support across message channels so omitted `accountId` routes to an explicit configured account instead of relying on implicit first-entry ordering (fallback behavior unchanged when unset).
|
||||
@@ -486,6 +533,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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; landed from contributor PR #23311 by @haitao-sjsu) Thanks @haitao-sjsu.
|
||||
- Feishu/Onboarding SecretRef guards: avoid direct `.trim()` calls on object-form `appId`/`appSecret` in onboarding credential checks, keep status semantics strict when an account explicitly sets empty `appId` (no fallback to top-level `appId`), recognize env SecretRef `appId`/`appSecret` as configured so readiness is accurate, and preserve unresolved SecretRef errors in default account resolution for actionable diagnostics. (#30903) Thanks @LiaoyuanNing.
|
||||
- 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.
|
||||
@@ -891,7 +939,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao.
|
||||
- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12.
|
||||
- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) Thanks @steipete.
|
||||
- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
|
||||
- Install/Discord Voice: make the native Opus decoder optional so `openclaw` install/update no longer hard-fails when native builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
|
||||
- Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep.
|
||||
- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) Thanks @steipete.
|
||||
- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
|
||||
|
||||
@@ -74,6 +74,7 @@ Welcome to the lobster tank! 🦞
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# 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,48 +1,16 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import OpenClawKit
|
||||
|
||||
enum KeychainStore {
|
||||
static func loadString(service: String, account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
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)
|
||||
GenericPasswordKeychainStore.loadString(service: service, account: account)
|
||||
}
|
||||
|
||||
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,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
GenericPasswordKeychainStore.saveString(value, service: service, account: account)
|
||||
}
|
||||
|
||||
static func delete(service: String, account: String) -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
GenericPasswordKeychainStore.delete(service: service, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,9 @@ final class NodeAppModel {
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||
|
||||
// Primary "node" connection: used for device capabilities and node.invoke requests.
|
||||
private let nodeGateway = GatewayNodeSession()
|
||||
@@ -2591,19 +2593,31 @@ extension NodeAppModel {
|
||||
"agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
|
||||
return
|
||||
}
|
||||
if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 5.0 {
|
||||
self.deepLinkLogger.debug("agent deep link prompt rate-limited (min 5 s interval)")
|
||||
return
|
||||
}
|
||||
self.lastAgentDeepLinkPromptAt = Date()
|
||||
|
||||
let urlText = originalURL.absoluteString
|
||||
let prompt = AgentDeepLinkPrompt(
|
||||
id: UUID().uuidString,
|
||||
messagePreview: message,
|
||||
urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText,
|
||||
request: self.effectiveAgentDeepLinkForPrompt(link))
|
||||
self.pendingAgentDeepLinkPrompt = prompt
|
||||
|
||||
let promptIntervalSeconds = 5.0
|
||||
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
|
||||
if elapsed < promptIntervalSeconds {
|
||||
if self.pendingAgentDeepLinkPrompt != nil {
|
||||
self.pendingAgentDeepLinkPrompt = prompt
|
||||
self.recordShareEvent("Updated local confirmation request (\(message.count) chars).")
|
||||
self.deepLinkLogger.debug("agent deep link prompt coalesced into active confirmation")
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = max(0, promptIntervalSeconds - elapsed)
|
||||
self.queueAgentDeepLinkPrompt(prompt, initialDelaySeconds: remaining)
|
||||
self.recordShareEvent("Queued local confirmation (\(message.count) chars).")
|
||||
self.deepLinkLogger.debug("agent deep link prompt queued due to rate limit")
|
||||
return
|
||||
}
|
||||
|
||||
self.presentAgentDeepLinkPrompt(prompt)
|
||||
self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link requires local confirmation")
|
||||
return
|
||||
@@ -2672,6 +2686,60 @@ extension NodeAppModel {
|
||||
self.deepLinkLogger.info("agent deep link cancelled by local user")
|
||||
}
|
||||
|
||||
private func presentAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt) {
|
||||
self.lastAgentDeepLinkPromptAt = Date()
|
||||
self.pendingAgentDeepLinkPrompt = prompt
|
||||
}
|
||||
|
||||
private func queueAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt, initialDelaySeconds: TimeInterval) {
|
||||
self.queuedAgentDeepLinkPrompt = prompt
|
||||
guard self.queuedAgentDeepLinkPromptTask == nil else { return }
|
||||
|
||||
self.queuedAgentDeepLinkPromptTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let delayNs = UInt64(max(0, initialDelaySeconds) * 1_000_000_000)
|
||||
if delayNs > 0 {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delayNs)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
await self.deliverQueuedAgentDeepLinkPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverQueuedAgentDeepLinkPrompt() async {
|
||||
defer { self.queuedAgentDeepLinkPromptTask = nil }
|
||||
let promptIntervalSeconds = 5.0
|
||||
while let prompt = self.queuedAgentDeepLinkPrompt {
|
||||
if self.pendingAgentDeepLinkPrompt != nil {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
|
||||
if elapsed < promptIntervalSeconds {
|
||||
let remaining = max(0, promptIntervalSeconds - elapsed)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
self.queuedAgentDeepLinkPrompt = nil
|
||||
self.presentAgentDeepLinkPrompt(prompt)
|
||||
self.recordShareEvent("Awaiting local confirmation (\(prompt.messagePreview.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link queued prompt delivered")
|
||||
}
|
||||
}
|
||||
|
||||
private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async {
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
|
||||
@@ -20,10 +20,11 @@ enum WatchMessagingError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
@MainActor
|
||||
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
|
||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private let session: WCSession?
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
|
||||
override init() {
|
||||
@@ -39,11 +40,11 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
}
|
||||
}
|
||||
|
||||
static func isSupportedOnDevice() -> Bool {
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
WCSession.isSupported()
|
||||
}
|
||||
|
||||
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard WCSession.isSupported() else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
@@ -70,9 +71,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandlerLock.lock()
|
||||
self.replyHandler = handler
|
||||
self.replyHandlerLock.unlock()
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
@@ -161,19 +160,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
let handler: ((WatchQuickReplyEvent) -> Void)?
|
||||
self.replyHandlerLock.lock()
|
||||
handler = self.replyHandler
|
||||
self.replyHandlerLock.unlock()
|
||||
handler?(event)
|
||||
self.replyHandler?(event)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
nonisolated private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func parseQuickReplyPayload(
|
||||
nonisolated private static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
@@ -205,13 +200,12 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated { return }
|
||||
session.activate()
|
||||
for _ in 0..<8 {
|
||||
if session.activationState == .activated { return }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
await withCheckedContinuation { continuation in
|
||||
self.pendingActivationContinuations.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
@@ -220,7 +214,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
activationState: activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
@@ -235,32 +229,42 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
}
|
||||
|
||||
extension WatchMessagingService: WCSessionDelegate {
|
||||
func session(
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: (any Error)?)
|
||||
{
|
||||
if let error {
|
||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
} else {
|
||||
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
// Always resume all waiters so callers never hang, even on error.
|
||||
Task { @MainActor in
|
||||
let waiters = self.pendingActivationContinuations
|
||||
self.pendingActivationContinuations.removeAll()
|
||||
for continuation in waiters {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
return
|
||||
}
|
||||
self.emitReply(event)
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
func session(
|
||||
nonisolated func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
@@ -270,15 +274,19 @@ extension WatchMessagingService: WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": true])
|
||||
self.emitReply(event)
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
||||
return
|
||||
}
|
||||
self.emitReply(event)
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,23 @@ import Observation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
private final class StreamFailureBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var valueInternal: Error?
|
||||
|
||||
func set(_ error: Error) {
|
||||
self.lock.lock()
|
||||
self.valueInternal = error
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
var value: Error? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.valueInternal
|
||||
}
|
||||
}
|
||||
|
||||
// This file intentionally centralizes talk mode state + behavior.
|
||||
// It's large, and splitting would force `private` -> `fileprivate` across many members.
|
||||
// We'll refactor into smaller files when the surface stabilizes.
|
||||
@@ -1040,7 +1057,7 @@ final class TalkModeManager: NSObject {
|
||||
let request = makeRequest(outputFormat: outputFormat)
|
||||
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
let rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
|
||||
if self.interruptOnSpeech {
|
||||
do {
|
||||
@@ -1055,12 +1072,16 @@ final class TalkModeManager: NSObject {
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat)
|
||||
let result: StreamingPlaybackResult
|
||||
if let sampleRate {
|
||||
let streamFailure = StreamFailureBox()
|
||||
let stream = Self.monitorStreamFailures(rawStream, failureBox: streamFailure)
|
||||
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_128")
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.pcmFormatUnavailable = true
|
||||
if Self.isPCMFormatRejectedByAPI(streamFailure.value) {
|
||||
self.pcmFormatUnavailable = true
|
||||
}
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
@@ -1070,7 +1091,7 @@ final class TalkModeManager: NSObject {
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
result = await self.mp3Player.play(stream: rawStream)
|
||||
}
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
@@ -1492,9 +1513,12 @@ final class TalkModeManager: NSObject {
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, 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 voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
|
||||
@@ -1542,6 +1566,39 @@ final class TalkModeManager: NSObject {
|
||||
self.pcmFormatUnavailable ? "mp3_44100_128" : "pcm_44100"
|
||||
}
|
||||
|
||||
private static func monitorStreamFailures(
|
||||
_ stream: AsyncThrowingStream<Data, Error>,
|
||||
failureBox: StreamFailureBox
|
||||
) -> AsyncThrowingStream<Data, Error>
|
||||
{
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
failureBox.set(error)
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func isPCMFormatRejectedByAPI(_ error: Error?) -> Bool {
|
||||
guard let error = error as NSError? else { return false }
|
||||
guard error.domain == "ElevenLabsTTS", error.code >= 400 else { return false }
|
||||
let message = (error.userInfo[NSLocalizedDescriptionKey] as? String ?? error.localizedDescription).lowercased()
|
||||
return message.contains("output_format")
|
||||
|| message.contains("pcm_")
|
||||
|| message.contains("pcm ")
|
||||
|| message.contains("subscription_required")
|
||||
}
|
||||
|
||||
private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
for chunk in chunks {
|
||||
@@ -1583,21 +1640,25 @@ final class TalkModeManager: NSObject {
|
||||
text: text,
|
||||
context: context,
|
||||
outputFormat: context.outputFormat)
|
||||
let stream: AsyncThrowingStream<Data, Error>
|
||||
let rawStream: AsyncThrowingStream<Data, Error>
|
||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
} else {
|
||||
stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
}
|
||||
let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat
|
||||
let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat)
|
||||
let result: StreamingPlaybackResult
|
||||
if let sampleRate {
|
||||
let streamFailure = StreamFailureBox()
|
||||
let stream = Self.monitorStreamFailures(rawStream, failureBox: streamFailure)
|
||||
self.lastPlaybackWasPCM = true
|
||||
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
|
||||
if Self.isPCMFormatRejectedByAPI(streamFailure.value) {
|
||||
self.pcmFormatUnavailable = true
|
||||
}
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
@@ -1611,7 +1672,7 @@ final class TalkModeManager: NSObject {
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
result = await self.mp3Player.play(stream: rawStream)
|
||||
}
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
@@ -1621,6 +1682,8 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private struct IncrementalSpeechBuffer {
|
||||
private static let softBoundaryMinChars = 72
|
||||
|
||||
private(set) var latestText: String = ""
|
||||
private(set) var directive: TalkDirective?
|
||||
private var spokenOffset: Int = 0
|
||||
@@ -1713,8 +1776,9 @@ private struct IncrementalSpeechBuffer {
|
||||
}
|
||||
|
||||
if !inCodeBlock {
|
||||
buffer.append(chars[idx])
|
||||
if Self.isBoundary(chars[idx]) {
|
||||
let currentChar = chars[idx]
|
||||
buffer.append(currentChar)
|
||||
if Self.isBoundary(currentChar) || Self.isSoftBoundary(currentChar, bufferedChars: buffer.count) {
|
||||
lastBoundary = idx + 1
|
||||
bufferAtBoundary = buffer
|
||||
inCodeBlockAtBoundary = inCodeBlock
|
||||
@@ -1741,6 +1805,10 @@ private struct IncrementalSpeechBuffer {
|
||||
private static func isBoundary(_ ch: Character) -> Bool {
|
||||
ch == "." || ch == "!" || ch == "?" || ch == "\n"
|
||||
}
|
||||
|
||||
private static func isSoftBoundary(_ ch: Character, bufferedChars: Int) -> Bool {
|
||||
bufferedChars >= Self.softBoundaryMinChars && ch.isWhitespace
|
||||
}
|
||||
}
|
||||
|
||||
extension TalkModeManager {
|
||||
@@ -2115,6 +2183,10 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
|
||||
#if DEBUG
|
||||
extension TalkModeManager {
|
||||
static func _test_isPCMFormatRejectedByAPI(_ error: Error?) -> Bool {
|
||||
self.isPCMFormatRejectedByAPI(error)
|
||||
}
|
||||
|
||||
func _test_seedTranscript(_ transcript: String) {
|
||||
self.lastTranscript = transcript
|
||||
self.lastHeard = Date()
|
||||
|
||||
@@ -416,6 +416,20 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkCoalescesPromptWhenRateLimited() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
|
||||
await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "first prompt"))
|
||||
let firstPrompt = try #require(appModel.pendingAgentDeepLinkPrompt)
|
||||
|
||||
await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "second prompt"))
|
||||
let coalescedPrompt = try #require(appModel.pendingAgentDeepLinkPrompt)
|
||||
|
||||
#expect(coalescedPrompt.id != firstPrompt.id)
|
||||
#expect(coalescedPrompt.messagePreview.contains("second prompt"))
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -28,4 +29,22 @@ import Testing
|
||||
let selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
|
||||
let error = NSError(
|
||||
domain: "ElevenLabsTTS",
|
||||
code: 403,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "ElevenLabs failed: 403 subscription_required output_format=pcm_44100",
|
||||
])
|
||||
#expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error))
|
||||
}
|
||||
|
||||
@Test func ignoresGenericPlaybackFailuresForPCMFormatRejection() {
|
||||
let error = NSError(
|
||||
domain: "StreamingAudio",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "queue enqueue failed"])
|
||||
#expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error) == false)
|
||||
}
|
||||
}
|
||||
|
||||
28
apps/ios/Tests/TalkModeIncrementalSpeechBufferTests.swift
Normal file
28
apps/ios/Tests/TalkModeIncrementalSpeechBufferTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct TalkModeIncrementalSpeechBufferTests {
|
||||
@Test func emitsSoftBoundaryBeforeTerminalPunctuation() {
|
||||
let manager = TalkModeManager(allowSimulatorCapture: true)
|
||||
manager._test_incrementalReset()
|
||||
|
||||
let partial =
|
||||
"We start speaking earlier by splitting this long stream chunk at a whitespace boundary before punctuation arrives"
|
||||
let segments = manager._test_incrementalIngest(partial, isFinal: false)
|
||||
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments[0].count >= 72)
|
||||
#expect(segments[0].count < partial.count)
|
||||
}
|
||||
|
||||
@Test func keepsShortChunkBufferedWithoutPunctuation() {
|
||||
let manager = TalkModeManager(allowSimulatorCapture: true)
|
||||
manager._test_incrementalReset()
|
||||
|
||||
let short = "short chunk without punctuation"
|
||||
let segments = manager._test_incrementalIngest(short, isFinal: false)
|
||||
|
||||
#expect(segments.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,324 +0,0 @@
|
||||
# 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
|
||||
@@ -1,399 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,376 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,318 +0,0 @@
|
||||
# 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 |
|
||||
@@ -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 = 4 * 1024 * 1024 // 4 MB
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ public enum GatewayTLSStore {
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
|
||||
let raw = self.keychainLoad(account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw?.isEmpty == false { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
self.keychainSave(value, account: stableID)
|
||||
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
@@ -45,43 +46,13 @@ public enum GatewayTLSStore {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
else { return }
|
||||
if self.keychainLoad(account: stableID) == nil {
|
||||
guard self.keychainSave(existing, account: stableID) else { return }
|
||||
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
|
||||
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, 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
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
@@ -99,7 +70,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 4 * 1024 * 1024
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum GenericPasswordKeychainStore {
|
||||
public static func loadString(service: String, account: String) -> String? {
|
||||
guard let data = self.loadData(service: service, account: account) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func saveString(
|
||||
_ value: String,
|
||||
service: String,
|
||||
account: String,
|
||||
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
) -> Bool {
|
||||
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func delete(service: String, account: String) -> Bool {
|
||||
let query = self.baseQuery(service: service, account: account)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
|
||||
private static func loadData(service: String, account: String) -> Data? {
|
||||
var query = self.baseQuery(service: service, account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[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 data
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func saveData(
|
||||
_ data: Data,
|
||||
service: String,
|
||||
account: String,
|
||||
accessible: CFString
|
||||
) -> Bool {
|
||||
let query = self.baseQuery(service: service, account: account)
|
||||
let previousData = self.loadData(service: service, account: account)
|
||||
|
||||
let deleteStatus = SecItemDelete(query as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
return false
|
||||
}
|
||||
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = accessible
|
||||
if SecItemAdd(insert as CFDictionary, nil) == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
|
||||
// Best-effort rollback: preserve prior value if replacement fails.
|
||||
guard let previousData else { return false }
|
||||
var rollback = query
|
||||
rollback[kSecValueData as String] = previousData
|
||||
rollback[kSecAttrAccessible as String] = accessible
|
||||
_ = SecItemDelete(query as CFDictionary)
|
||||
_ = SecItemAdd(rollback as CFDictionary, nil)
|
||||
return false
|
||||
}
|
||||
|
||||
private static func baseQuery(service: String, account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
private let synth = AVSpeechSynthesizer()
|
||||
private var speakContinuation: CheckedContinuation<Void, Error>?
|
||||
private var currentUtterance: AVSpeechUtterance?
|
||||
private var didStartCallback: (() -> Void)?
|
||||
private var currentToken = UUID()
|
||||
private var watchdog: Task<Void, Never>?
|
||||
|
||||
@@ -26,17 +27,23 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
self.currentToken = UUID()
|
||||
self.watchdog?.cancel()
|
||||
self.watchdog = nil
|
||||
self.didStartCallback = nil
|
||||
self.synth.stopSpeaking(at: .immediate)
|
||||
self.finishCurrent(with: SpeakError.canceled)
|
||||
}
|
||||
|
||||
public func speak(text: String, language: String? = nil) async throws {
|
||||
public func speak(
|
||||
text: String,
|
||||
language: String? = nil,
|
||||
onStart: (() -> Void)? = nil
|
||||
) async throws {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.stop()
|
||||
let token = UUID()
|
||||
self.currentToken = token
|
||||
self.didStartCallback = onStart
|
||||
|
||||
let utterance = AVSpeechUtterance(string: trimmed)
|
||||
if let language, let voice = AVSpeechSynthesisVoice(language: language) {
|
||||
@@ -76,8 +83,13 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFinish(error: Error?) {
|
||||
guard self.currentUtterance != nil else { return }
|
||||
private func matchesCurrentUtterance(_ utteranceID: ObjectIdentifier) -> Bool {
|
||||
guard let currentUtterance = self.currentUtterance else { return false }
|
||||
return ObjectIdentifier(currentUtterance) == utteranceID
|
||||
}
|
||||
|
||||
private func handleFinish(utteranceID: ObjectIdentifier, error: Error?) {
|
||||
guard self.matchesCurrentUtterance(utteranceID) else { return }
|
||||
self.watchdog?.cancel()
|
||||
self.watchdog = nil
|
||||
self.finishCurrent(with: error)
|
||||
@@ -85,6 +97,7 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
|
||||
private func finishCurrent(with error: Error?) {
|
||||
self.currentUtterance = nil
|
||||
self.didStartCallback = nil
|
||||
let cont = self.speakContinuation
|
||||
self.speakContinuation = nil
|
||||
if let error {
|
||||
@@ -96,12 +109,26 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
}
|
||||
|
||||
extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate {
|
||||
public nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didStart utterance: AVSpeechUtterance)
|
||||
{
|
||||
let utteranceID = ObjectIdentifier(utterance)
|
||||
Task { @MainActor in
|
||||
guard self.matchesCurrentUtterance(utteranceID) else { return }
|
||||
let callback = self.didStartCallback
|
||||
self.didStartCallback = nil
|
||||
callback?()
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didFinish utterance: AVSpeechUtterance)
|
||||
{
|
||||
let utteranceID = ObjectIdentifier(utterance)
|
||||
Task { @MainActor in
|
||||
self.handleFinish(error: nil)
|
||||
self.handleFinish(utteranceID: utteranceID, error: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +136,9 @@ extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate {
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didCancel utterance: AVSpeechUtterance)
|
||||
{
|
||||
let utteranceID = ObjectIdentifier(utterance)
|
||||
Task { @MainActor in
|
||||
self.handleFinish(error: SpeakError.canceled)
|
||||
self.handleFinish(utteranceID: utteranceID, error: SpeakError.canceled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog/fragments/pr-30356.md
Normal file
1
changelog/fragments/pr-30356.md
Normal file
@@ -0,0 +1 @@
|
||||
- Security/Media route: add `X-Content-Type-Options: nosniff` header regression assertions for successful and not-found media responses (#30356) (thanks @13otKmdr)
|
||||
45
docs/auth-credential-semantics.md
Normal file
45
docs/auth-credential-semantics.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Auth Credential Semantics
|
||||
|
||||
This document defines the canonical credential eligibility and resolution semantics used across:
|
||||
|
||||
- `resolveAuthProfileOrder`
|
||||
- `resolveApiKeyForProfile`
|
||||
- `models status --probe`
|
||||
- `doctor-auth`
|
||||
|
||||
The goal is to keep selection-time and runtime behavior aligned.
|
||||
|
||||
## Stable Reason Codes
|
||||
|
||||
- `ok`
|
||||
- `missing_credential`
|
||||
- `invalid_expires`
|
||||
- `expired`
|
||||
- `unresolved_ref`
|
||||
|
||||
## Token Credentials
|
||||
|
||||
Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
|
||||
|
||||
### Eligibility rules
|
||||
|
||||
1. A token profile is ineligible when both `token` and `tokenRef` are absent.
|
||||
2. `expires` is optional.
|
||||
3. If `expires` is present, it must be a finite number greater than `0`.
|
||||
4. If `expires` is invalid (`NaN`, `0`, negative, non-finite, or wrong type), the profile is ineligible with `invalid_expires`.
|
||||
5. If `expires` is in the past, the profile is ineligible with `expired`.
|
||||
6. `tokenRef` does not bypass `expires` validation.
|
||||
|
||||
### Resolution rules
|
||||
|
||||
1. Resolver semantics match eligibility semantics for `expires`.
|
||||
2. For eligible profiles, token material may be resolved from inline value or `tokenRef`.
|
||||
3. Unresolvable refs produce `unresolved_ref` in `models status --probe` output.
|
||||
|
||||
## Legacy-Compatible Messaging
|
||||
|
||||
For script compatibility, probe errors keep this first line unchanged:
|
||||
|
||||
`Auth profile credentials are missing or expired.`
|
||||
|
||||
Human-friendly detail and stable reason codes may be added on subsequent lines.
|
||||
@@ -133,6 +133,8 @@ openclaw gateway
|
||||
DISCORD_BOT_TOKEN=...
|
||||
```
|
||||
|
||||
SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -419,6 +421,7 @@ Example:
|
||||
guilds: {
|
||||
"123456789012345678": {
|
||||
requireMention: true,
|
||||
ignoreOtherMentions: true,
|
||||
users: ["987654321098765432"],
|
||||
roles: ["123456789012345678"],
|
||||
channels: {
|
||||
@@ -446,6 +449,7 @@ Example:
|
||||
- implicit reply-to-bot behavior in supported cases
|
||||
|
||||
`requireMention` is configured per guild/channel (`channels.discord.guilds...`).
|
||||
`ignoreOtherMentions` optionally drops messages that mention another user/role but not the bot (excluding @everyone/@here).
|
||||
|
||||
Group DMs:
|
||||
|
||||
@@ -786,7 +790,7 @@ Default slash command settings:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Presence configuration">
|
||||
Presence updates are applied only when you set a status or activity field.
|
||||
Presence updates are applied when you set a status or activity field, or when you enable auto presence.
|
||||
|
||||
Status only example:
|
||||
|
||||
@@ -836,6 +840,29 @@ Default slash command settings:
|
||||
- 4: Custom (uses the activity text as the status state; emoji is optional)
|
||||
- 5: Competing
|
||||
|
||||
Auto presence example (runtime health signal):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
autoPresence: {
|
||||
enabled: true,
|
||||
intervalMs: 30000,
|
||||
minUpdateIntervalMs: 15000,
|
||||
exhaustedText: "token exhausted",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Auto presence maps runtime availability to Discord status: healthy => online, degraded or unknown => idle, exhausted or unavailable => dnd. Optional text overrides:
|
||||
|
||||
- `autoPresence.healthyText`
|
||||
- `autoPresence.degradedText`
|
||||
- `autoPresence.exhaustedText` (supports `{reason}` placeholder)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Exec approvals in Discord">
|
||||
@@ -1057,6 +1084,7 @@ openclaw logs --follow
|
||||
By default bot-authored messages are ignored.
|
||||
|
||||
If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior.
|
||||
Prefer `channels.discord.allowBots="mentions"` to only accept bot messages that mention the bot.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
|
||||
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
|
||||
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`).
|
||||
|
||||
## Skills: what’s injected vs loaded on-demand
|
||||
|
||||
The system prompt includes a compact **skills list** (name + description + location). This list has real overhead.
|
||||
|
||||
@@ -73,7 +73,10 @@ compaction.
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
|
||||
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
|
||||
(default: 150000). Missing files inject a short missing-file marker.
|
||||
(default: 150000). Missing files inject a short missing-file marker. When truncation
|
||||
occurs, OpenClaw can inject a warning block in Project Context; control this with
|
||||
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
|
||||
default: `once`).
|
||||
|
||||
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
|
||||
are filtered out to keep the sub-agent context small).
|
||||
|
||||
@@ -1182,6 +1182,7 @@
|
||||
"gateway/configuration-reference",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"auth-credential-semantics",
|
||||
"gateway/secrets",
|
||||
"gateway/secrets-plan-contract",
|
||||
"gateway/trusted-proxy-auth",
|
||||
|
||||
@@ -15,6 +15,8 @@ flows are also supported when they match your provider account model.
|
||||
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
|
||||
layout.
|
||||
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
|
||||
For credential eligibility/reason-code rules used by `models status --probe`, see
|
||||
[Auth Credential Semantics](/auth-credential-semantics).
|
||||
|
||||
## Recommended setup (API key, any provider)
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
"123456789012345678": {
|
||||
slug: "friends-of-openclaw",
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
reactionNotifications: "own",
|
||||
users: ["987654321098765432"],
|
||||
channels: {
|
||||
@@ -305,7 +306,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
|
||||
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them; use `allowBots: "mentions"` to only accept bot messages that mention the bot (own messages still filtered).
|
||||
- `channels.discord.guilds.<id>.ignoreOtherMentions` (and channel overrides) drops messages that mention another user or role but not the bot (excluding @everyone/@here).
|
||||
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
|
||||
- `channels.discord.threadBindings` controls Discord thread-bound routing:
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
|
||||
@@ -317,6 +319,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default).
|
||||
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
|
||||
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
|
||||
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
|
||||
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
|
||||
|
||||
**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from `guilds.<id>.users` on all messages).
|
||||
@@ -798,6 +801,21 @@ Max total characters injected across all workspace bootstrap files. Default: `15
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.bootstrapPromptTruncationWarning`
|
||||
|
||||
Controls agent-visible warning text when bootstrap context is truncated.
|
||||
Default: `"once"`.
|
||||
|
||||
- `"off"`: never inject warning text into the system prompt.
|
||||
- `"once"`: inject warning once per unique truncation signature (recommended).
|
||||
- `"always"`: inject warning on every run when truncation exists.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapPromptTruncationWarning: "once" } }, // off | once | always
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.imageMaxDimensionPx`
|
||||
|
||||
Max pixel size for the longest image side in transcript/tool image blocks before provider calls.
|
||||
|
||||
@@ -200,7 +200,7 @@ Use this when auditing access or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
@@ -630,7 +630,56 @@ Rules of thumb:
|
||||
- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly.
|
||||
- Never expose the Gateway unauthenticated on `0.0.0.0`.
|
||||
|
||||
### 0.4.1) mDNS/Bonjour discovery (information disclosure)
|
||||
### 0.4.1) Docker port publishing + UFW (`DOCKER-USER`)
|
||||
|
||||
If you run OpenClaw with Docker on a VPS, remember that published container ports
|
||||
(`-p HOST:CONTAINER` or Compose `ports:`) are routed through Docker's forwarding
|
||||
chains, not only host `INPUT` rules.
|
||||
|
||||
To keep Docker traffic aligned with your firewall policy, enforce rules in
|
||||
`DOCKER-USER` (this chain is evaluated before Docker's own accept rules).
|
||||
On many modern distros, `iptables`/`ip6tables` use the `iptables-nft` frontend
|
||||
and still apply these rules to the nftables backend.
|
||||
|
||||
Minimal allowlist example (IPv4):
|
||||
|
||||
```bash
|
||||
# /etc/ufw/after.rules (append as its own *filter section)
|
||||
*filter
|
||||
:DOCKER-USER - [0:0]
|
||||
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
|
||||
-A DOCKER-USER -s 127.0.0.0/8 -j RETURN
|
||||
-A DOCKER-USER -s 10.0.0.0/8 -j RETURN
|
||||
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
|
||||
-A DOCKER-USER -s 192.168.0.0/16 -j RETURN
|
||||
-A DOCKER-USER -s 100.64.0.0/10 -j RETURN
|
||||
-A DOCKER-USER -p tcp --dport 80 -j RETURN
|
||||
-A DOCKER-USER -p tcp --dport 443 -j RETURN
|
||||
-A DOCKER-USER -m conntrack --ctstate NEW -j DROP
|
||||
-A DOCKER-USER -j RETURN
|
||||
COMMIT
|
||||
```
|
||||
|
||||
IPv6 has separate tables. Add a matching policy in `/etc/ufw/after6.rules` if
|
||||
Docker IPv6 is enabled.
|
||||
|
||||
Avoid hardcoding interface names like `eth0` in docs snippets. Interface names
|
||||
vary across VPS images (`ens3`, `enp*`, etc.) and mismatches can accidentally
|
||||
skip your deny rule.
|
||||
|
||||
Quick validation after reload:
|
||||
|
||||
```bash
|
||||
ufw reload
|
||||
iptables -S DOCKER-USER
|
||||
ip6tables -S DOCKER-USER
|
||||
nmap -sT -p 1-65535 <public-ip> --open
|
||||
```
|
||||
|
||||
Expected external ports should be only what you intentionally expose (for most
|
||||
setups: SSH + your reverse proxy ports).
|
||||
|
||||
### 0.4.2) mDNS/Bonjour discovery (information disclosure)
|
||||
|
||||
The Gateway broadcasts its presence via mDNS (`_openclaw-gw._tcp` on port 5353) for local device discovery. In full mode, this includes TXT records that may expose operational details:
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing)
|
||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
||||
- At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137)
|
||||
- Enough disk for images + logs
|
||||
- If running on a VPS/public host, review
|
||||
[Security hardening for network exposure](/gateway/security#04-network-exposure-bind--port--firewall),
|
||||
especially Docker `DOCKER-USER` firewall policy.
|
||||
|
||||
## Containerized Gateway (Docker Compose)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Scope intent:
|
||||
|
||||
### `openclaw.json` targets (`secrets configure` + `secrets apply` + `secrets audit`)
|
||||
|
||||
<!-- secretref-supported-list-start -->
|
||||
[//]: # "secretref-supported-list-start"
|
||||
|
||||
- `models.providers.*.apiKey`
|
||||
- `skills.entries.*.apiKey`
|
||||
@@ -89,7 +89,8 @@ Scope intent:
|
||||
|
||||
- `profiles.*.keyRef` (`type: "api_key"`)
|
||||
- `profiles.*.tokenRef` (`type: "token"`)
|
||||
<!-- secretref-supported-list-end -->
|
||||
|
||||
[//]: # "secretref-supported-list-end"
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -104,7 +105,7 @@ Notes:
|
||||
|
||||
Out-of-scope credentials include:
|
||||
|
||||
<!-- secretref-unsupported-list-start -->
|
||||
[//]: # "secretref-unsupported-list-start"
|
||||
|
||||
- `gateway.auth.token`
|
||||
- `commands.ownerDisplaySecret`
|
||||
@@ -116,7 +117,8 @@ Out-of-scope credentials include:
|
||||
- `auth-profiles.oauth.*`
|
||||
- `discord.threadBindings.*.webhookToken`
|
||||
- `whatsapp.creds.json`
|
||||
<!-- secretref-unsupported-list-end -->
|
||||
|
||||
[//]: # "secretref-unsupported-list-end"
|
||||
|
||||
Rationale:
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ If you're unsure about the risk level, just describe the impact and we'll assess
|
||||
- [ATLAS Website](https://atlas.mitre.org/)
|
||||
- [ATLAS Techniques](https://atlas.mitre.org/techniques/)
|
||||
- [ATLAS Case Studies](https://atlas.mitre.org/studies/)
|
||||
- [OpenClaw Threat Model](./THREAT-MODEL-ATLAS.md)
|
||||
- [OpenClaw Threat Model](/security/THREAT-MODEL-ATLAS)
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## Documents
|
||||
|
||||
- [Threat Model](./THREAT-MODEL-ATLAS.md) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
|
||||
- [Contributing to the Threat Model](./CONTRIBUTING-THREAT-MODEL.md) - How to add threats, mitigations, and attack chains
|
||||
- [Threat Model](/security/THREAT-MODEL-ATLAS) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
|
||||
- [Contributing to the Threat Model](/security/CONTRIBUTING-THREAT-MODEL) - How to add threats, mitigations, and attack chains
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the indus
|
||||
|
||||
### Contributing to This Threat Model
|
||||
|
||||
This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](./CONTRIBUTING-THREAT-MODEL.md) for guidelines on contributing:
|
||||
This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](/security/CONTRIBUTING-THREAT-MODEL) for guidelines on contributing:
|
||||
|
||||
- Reporting new threats
|
||||
- Updating existing threats
|
||||
|
||||
@@ -128,7 +128,7 @@ Use this when debugging auth or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Discord bot token**: config/env or SecretRef (env/file/exec providers)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
|
||||
@@ -30,14 +30,14 @@ Global defaults:
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false,
|
||||
historySize: 20,
|
||||
detectorCooldownMs: 12000,
|
||||
repeatThreshold: 3,
|
||||
criticalThreshold: 6,
|
||||
historySize: 30,
|
||||
warningThreshold: 10,
|
||||
criticalThreshold: 20,
|
||||
globalCircuitBreakerThreshold: 30,
|
||||
detectors: {
|
||||
repeatedFailure: true,
|
||||
knownPollLoop: true,
|
||||
repeatingNoProgress: true,
|
||||
genericRepeat: true,
|
||||
knownPollNoProgress: true,
|
||||
pingPong: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,8 +55,8 @@ Per-agent override (optional):
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
repeatThreshold: 2,
|
||||
criticalThreshold: 5,
|
||||
warningThreshold: 8,
|
||||
criticalThreshold: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -69,18 +69,20 @@ Per-agent override (optional):
|
||||
|
||||
- `enabled`: Master switch. `false` means no loop detection is performed.
|
||||
- `historySize`: number of recent tool calls kept for analysis.
|
||||
- `detectorCooldownMs`: time window used by the no-progress detector.
|
||||
- `repeatThreshold`: minimum repeats before warning/blocking starts.
|
||||
- `criticalThreshold`: stronger threshold that can trigger stricter handling.
|
||||
- `detectors.repeatedFailure`: detects repeated failed attempts on the same call path.
|
||||
- `detectors.knownPollLoop`: detects known polling-like loops.
|
||||
- `detectors.repeatingNoProgress`: detects high-frequency repeated calls without state change.
|
||||
- `warningThreshold`: threshold before classifying a pattern as warning-only.
|
||||
- `criticalThreshold`: threshold for blocking repetitive loop patterns.
|
||||
- `globalCircuitBreakerThreshold`: global no-progress breaker threshold.
|
||||
- `detectors.genericRepeat`: detects repeated same-tool + same-params patterns.
|
||||
- `detectors.knownPollNoProgress`: detects known polling-like patterns with no state change.
|
||||
- `detectors.pingPong`: detects alternating ping-pong patterns.
|
||||
|
||||
## Recommended setup
|
||||
|
||||
- Start with `enabled: true`, defaults unchanged.
|
||||
- Keep thresholds ordered as `warningThreshold < criticalThreshold < globalCircuitBreakerThreshold`.
|
||||
- If false positives occur:
|
||||
- raise `repeatThreshold` and/or `criticalThreshold`
|
||||
- raise `warningThreshold` and/or `criticalThreshold`
|
||||
- (optionally) raise `globalCircuitBreakerThreshold`
|
||||
- disable only the detector causing issues
|
||||
- reduce `historySize` for less strict historical context
|
||||
|
||||
|
||||
@@ -106,6 +106,26 @@ Notes:
|
||||
- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
|
||||
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
|
||||
|
||||
## Plugin SDK import paths
|
||||
|
||||
Use SDK subpaths instead of the monolithic `openclaw/plugin-sdk` import when
|
||||
authoring plugins:
|
||||
|
||||
- `openclaw/plugin-sdk/core` for generic plugin APIs, provider auth types, and shared helpers.
|
||||
- `openclaw/plugin-sdk/telegram` for Telegram channel plugins.
|
||||
- `openclaw/plugin-sdk/discord` for Discord channel plugins.
|
||||
- `openclaw/plugin-sdk/slack` for Slack channel plugins.
|
||||
- `openclaw/plugin-sdk/signal` for Signal channel plugins.
|
||||
- `openclaw/plugin-sdk/imessage` for iMessage channel plugins.
|
||||
- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugins.
|
||||
- `openclaw/plugin-sdk/line` for LINE channel plugins.
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- `openclaw/plugin-sdk` remains supported for existing external plugins.
|
||||
- New and migrated bundled plugins should use channel subpaths (or `core`) to
|
||||
keep startup imports scoped.
|
||||
|
||||
## Discovery & precedence
|
||||
|
||||
OpenClaw scans, in order:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config.js";
|
||||
import { createAcpxRuntimeService } from "./src/service.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import os from "node:os";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
resolveGatewayBindUrl,
|
||||
runPluginCommandWithTimeout,
|
||||
resolveTailnetHostWithRunner,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import qrcode from "qrcode-terminal";
|
||||
import {
|
||||
armPairNotifyOnce,
|
||||
formatPendingRequests,
|
||||
handleNotifyCommand,
|
||||
registerPairingNotifierService,
|
||||
} from "./notify.js";
|
||||
|
||||
function renderQrAscii(data: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -317,36 +323,9 @@ function formatSetupInstructions(): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type PendingPairingRequest = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
function formatPendingRequests(pending: PendingPairingRequest[]): string {
|
||||
if (pending.length === 0) {
|
||||
return "No pending device pairing requests.";
|
||||
}
|
||||
const lines: string[] = ["Pending device pairing requests:"];
|
||||
for (const req of pending) {
|
||||
const label = req.displayName?.trim() || req.deviceId;
|
||||
const platform = req.platform?.trim();
|
||||
const ip = req.remoteIp?.trim();
|
||||
const parts = [
|
||||
`- ${req.requestId}`,
|
||||
label ? `name=${label}` : null,
|
||||
platform ? `platform=${platform}` : null,
|
||||
ip ? `ip=${ip}` : null,
|
||||
].filter(Boolean);
|
||||
lines.push(parts.join(" · "));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export default function register(api: OpenClawPluginApi) {
|
||||
registerPairingNotifierService(api);
|
||||
|
||||
api.registerCommand({
|
||||
name: "pair",
|
||||
description: "Generate setup codes and approve device pairing requests.",
|
||||
@@ -366,6 +345,15 @@ export default function register(api: OpenClawPluginApi) {
|
||||
return { text: formatPendingRequests(list.pending) };
|
||||
}
|
||||
|
||||
if (action === "notify") {
|
||||
const notifyAction = tokens[1]?.trim().toLowerCase() ?? "status";
|
||||
return await handleNotifyCommand({
|
||||
api,
|
||||
ctx,
|
||||
action: notifyAction,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
const requested = tokens[1]?.trim();
|
||||
const list = await listDevicePairing();
|
||||
@@ -428,6 +416,19 @@ export default function register(api: OpenClawPluginApi) {
|
||||
|
||||
const channel = ctx.channel;
|
||||
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||
let autoNotifyArmed = false;
|
||||
|
||||
if (channel === "telegram" && target) {
|
||||
try {
|
||||
autoNotifyArmed = await armPairNotifyOnce({ api, ctx });
|
||||
} catch (err) {
|
||||
api.logger.warn?.(
|
||||
`device-pair: failed to arm one-shot pairing notify (${String(
|
||||
(err as Error)?.message ?? err,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === "telegram" && target) {
|
||||
try {
|
||||
@@ -448,7 +449,15 @@ export default function register(api: OpenClawPluginApi) {
|
||||
`Gateway: ${payload.url}`,
|
||||
`Auth: ${authLabel}`,
|
||||
"",
|
||||
"After scanning, come back here and run `/pair approve` to complete pairing.",
|
||||
autoNotifyArmed
|
||||
? "After scanning, wait here for the pairing request ping."
|
||||
: "After scanning, come back here and run `/pair approve` to complete pairing.",
|
||||
...(autoNotifyArmed
|
||||
? [
|
||||
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
||||
"If the ping does not arrive, run `/pair approve latest` manually.",
|
||||
]
|
||||
: []),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -467,7 +476,15 @@ export default function register(api: OpenClawPluginApi) {
|
||||
`Gateway: ${payload.url}`,
|
||||
`Auth: ${authLabel}`,
|
||||
"",
|
||||
"After scanning, run `/pair approve` to complete pairing.",
|
||||
autoNotifyArmed
|
||||
? "After scanning, wait here for the pairing request ping."
|
||||
: "After scanning, run `/pair approve` to complete pairing.",
|
||||
...(autoNotifyArmed
|
||||
? [
|
||||
"I’ll auto-ping here when the pairing request arrives, then auto-disable.",
|
||||
"If the ping does not arrive, run `/pair approve latest` manually.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// WebUI + CLI/TUI: ASCII QR
|
||||
|
||||
460
extensions/device-pair/notify.ts
Normal file
460
extensions/device-pair/notify.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { listDevicePairing } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const NOTIFY_STATE_FILE = "device-pair-notify.json";
|
||||
const NOTIFY_POLL_INTERVAL_MS = 10_000;
|
||||
const NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type NotifySubscription = {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
mode: "persistent" | "once";
|
||||
addedAtMs: number;
|
||||
};
|
||||
|
||||
type NotifyStateFile = {
|
||||
subscribers: NotifySubscription[];
|
||||
notifiedRequestIds: Record<string, number>;
|
||||
};
|
||||
|
||||
export type PendingPairingRequest = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
remoteIp?: string;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export function formatPendingRequests(pending: PendingPairingRequest[]): string {
|
||||
if (pending.length === 0) {
|
||||
return "No pending device pairing requests.";
|
||||
}
|
||||
const lines: string[] = ["Pending device pairing requests:"];
|
||||
for (const req of pending) {
|
||||
const label = req.displayName?.trim() || req.deviceId;
|
||||
const platform = req.platform?.trim();
|
||||
const ip = req.remoteIp?.trim();
|
||||
const parts = [
|
||||
`- ${req.requestId}`,
|
||||
label ? `name=${label}` : null,
|
||||
platform ? `platform=${platform}` : null,
|
||||
ip ? `ip=${ip}` : null,
|
||||
].filter(Boolean);
|
||||
lines.push(parts.join(" · "));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveNotifyStatePath(stateDir: string): string {
|
||||
return path.join(stateDir, NOTIFY_STATE_FILE);
|
||||
}
|
||||
|
||||
function normalizeNotifyState(raw: unknown): NotifyStateFile {
|
||||
const root = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
const subscribersRaw = Array.isArray(root.subscribers) ? root.subscribers : [];
|
||||
const notifiedRaw =
|
||||
typeof root.notifiedRequestIds === "object" && root.notifiedRequestIds !== null
|
||||
? (root.notifiedRequestIds as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const subscribers: NotifySubscription[] = [];
|
||||
for (const item of subscribersRaw) {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
continue;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const to = typeof record.to === "string" ? record.to.trim() : "";
|
||||
if (!to) {
|
||||
continue;
|
||||
}
|
||||
const accountId =
|
||||
typeof record.accountId === "string" && record.accountId.trim()
|
||||
? record.accountId.trim()
|
||||
: undefined;
|
||||
const messageThreadId =
|
||||
typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
|
||||
? Math.trunc(record.messageThreadId)
|
||||
: undefined;
|
||||
const mode = record.mode === "once" ? "once" : "persistent";
|
||||
const addedAtMs =
|
||||
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
|
||||
? Math.trunc(record.addedAtMs)
|
||||
: Date.now();
|
||||
subscribers.push({
|
||||
to,
|
||||
accountId,
|
||||
messageThreadId,
|
||||
mode,
|
||||
addedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
const notifiedRequestIds: Record<string, number> = {};
|
||||
for (const [requestId, ts] of Object.entries(notifiedRaw)) {
|
||||
if (!requestId.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
|
||||
continue;
|
||||
}
|
||||
notifiedRequestIds[requestId] = Math.trunc(ts);
|
||||
}
|
||||
|
||||
return { subscribers, notifiedRequestIds };
|
||||
}
|
||||
|
||||
async function readNotifyState(filePath: string): Promise<NotifyStateFile> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
return normalizeNotifyState(JSON.parse(content));
|
||||
} catch {
|
||||
return { subscribers: [], notifiedRequestIds: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNotifyState(filePath: string, state: NotifyStateFile): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const content = JSON.stringify(state, null, 2);
|
||||
await fs.writeFile(filePath, `${content}\n`, "utf8");
|
||||
}
|
||||
|
||||
function notifySubscriberKey(subscriber: {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
}): string {
|
||||
return [subscriber.to, subscriber.accountId ?? "", subscriber.messageThreadId ?? ""].join("|");
|
||||
}
|
||||
|
||||
type NotifyTarget = {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
function resolveNotifyTarget(ctx: {
|
||||
senderId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
}): NotifyTarget | null {
|
||||
const to = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to,
|
||||
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
||||
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function upsertNotifySubscriber(
|
||||
subscribers: NotifySubscription[],
|
||||
target: NotifyTarget,
|
||||
mode: NotifySubscription["mode"],
|
||||
): boolean {
|
||||
const key = notifySubscriberKey(target);
|
||||
const index = subscribers.findIndex((entry) => notifySubscriberKey(entry) === key);
|
||||
const next: NotifySubscription = {
|
||||
...target,
|
||||
mode,
|
||||
addedAtMs: Date.now(),
|
||||
};
|
||||
if (index === -1) {
|
||||
subscribers.push(next);
|
||||
return true;
|
||||
}
|
||||
const existing = subscribers[index];
|
||||
if (existing?.mode === mode) {
|
||||
return false;
|
||||
}
|
||||
subscribers[index] = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPairingRequestNotificationText(request: PendingPairingRequest): string {
|
||||
const label = request.displayName?.trim() || request.deviceId;
|
||||
const platform = request.platform?.trim();
|
||||
const ip = request.remoteIp?.trim();
|
||||
const lines = [
|
||||
"📲 New device pairing request",
|
||||
`ID: ${request.requestId}`,
|
||||
`Name: ${label}`,
|
||||
...(platform ? [`Platform: ${platform}`] : []),
|
||||
...(ip ? [`IP: ${ip}`] : []),
|
||||
"",
|
||||
`Approve: /pair approve ${request.requestId}`,
|
||||
"List pending: /pair pending",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function requestTimestampMs(request: PendingPairingRequest): number | null {
|
||||
if (typeof request.ts !== "number" || !Number.isFinite(request.ts)) {
|
||||
return null;
|
||||
}
|
||||
const ts = Math.trunc(request.ts);
|
||||
return ts > 0 ? ts : null;
|
||||
}
|
||||
|
||||
function shouldNotifySubscriberForRequest(
|
||||
subscriber: NotifySubscription,
|
||||
request: PendingPairingRequest,
|
||||
): boolean {
|
||||
if (subscriber.mode !== "once") {
|
||||
return true;
|
||||
}
|
||||
const ts = requestTimestampMs(request);
|
||||
// One-shot subscriptions should only notify for new requests created after arming.
|
||||
if (ts == null) {
|
||||
return false;
|
||||
}
|
||||
return ts >= subscriber.addedAtMs;
|
||||
}
|
||||
|
||||
async function notifySubscriber(params: {
|
||||
api: OpenClawPluginApi;
|
||||
subscriber: NotifySubscription;
|
||||
text: string;
|
||||
}): Promise<boolean> {
|
||||
const send = params.api.runtime?.channel?.telegram?.sendMessageTelegram;
|
||||
if (!send) {
|
||||
params.api.logger.warn("device-pair: telegram runtime unavailable for pairing notifications");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await send(params.subscriber.to, params.text, {
|
||||
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
|
||||
...(params.subscriber.messageThreadId != null
|
||||
? { messageThreadId: params.subscriber.messageThreadId }
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
params.api.logger.warn(
|
||||
`device-pair: failed to send pairing notification to ${params.subscriber.to}: ${String(
|
||||
(err as Error)?.message ?? err,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyPendingPairingRequests(params: {
|
||||
api: OpenClawPluginApi;
|
||||
statePath: string;
|
||||
}): Promise<void> {
|
||||
const state = await readNotifyState(params.statePath);
|
||||
const pairing = await listDevicePairing();
|
||||
const pending = pairing.pending as PendingPairingRequest[];
|
||||
const now = Date.now();
|
||||
const pendingIds = new Set(pending.map((entry) => entry.requestId));
|
||||
let changed = false;
|
||||
|
||||
for (const [requestId, ts] of Object.entries(state.notifiedRequestIds)) {
|
||||
if (!pendingIds.has(requestId) || now - ts > NOTIFY_MAX_SEEN_AGE_MS) {
|
||||
delete state.notifiedRequestIds[requestId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.subscribers.length > 0) {
|
||||
const oneShotDelivered = new Set<string>();
|
||||
for (const request of pending) {
|
||||
if (state.notifiedRequestIds[request.requestId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = buildPairingRequestNotificationText(request);
|
||||
let delivered = false;
|
||||
for (const subscriber of state.subscribers) {
|
||||
if (!shouldNotifySubscriberForRequest(subscriber, request)) {
|
||||
continue;
|
||||
}
|
||||
const sent = await notifySubscriber({
|
||||
api: params.api,
|
||||
subscriber,
|
||||
text,
|
||||
});
|
||||
delivered = delivered || sent;
|
||||
if (sent && subscriber.mode === "once") {
|
||||
oneShotDelivered.add(notifySubscriberKey(subscriber));
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered) {
|
||||
state.notifiedRequestIds[request.requestId] = now;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (oneShotDelivered.size > 0) {
|
||||
const initialCount = state.subscribers.length;
|
||||
state.subscribers = state.subscribers.filter(
|
||||
(subscriber) => !oneShotDelivered.has(notifySubscriberKey(subscriber)),
|
||||
);
|
||||
if (state.subscribers.length !== initialCount) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeNotifyState(params.statePath, state);
|
||||
}
|
||||
}
|
||||
|
||||
export async function armPairNotifyOnce(params: {
|
||||
api: OpenClawPluginApi;
|
||||
ctx: {
|
||||
channel: string;
|
||||
senderId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
}): Promise<boolean> {
|
||||
if (params.ctx.channel !== "telegram") {
|
||||
return false;
|
||||
}
|
||||
const target = resolveNotifyTarget(params.ctx);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stateDir = params.api.runtime.state.resolveStateDir();
|
||||
const statePath = resolveNotifyStatePath(stateDir);
|
||||
const state = await readNotifyState(statePath);
|
||||
let changed = false;
|
||||
|
||||
if (upsertNotifySubscriber(state.subscribers, target, "once")) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeNotifyState(statePath, state);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleNotifyCommand(params: {
|
||||
api: OpenClawPluginApi;
|
||||
ctx: {
|
||||
channel: string;
|
||||
senderId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
messageThreadId?: number;
|
||||
};
|
||||
action: string;
|
||||
}): Promise<{ text: string }> {
|
||||
if (params.ctx.channel !== "telegram") {
|
||||
return { text: "Pairing notifications are currently supported only on Telegram." };
|
||||
}
|
||||
|
||||
const target = resolveNotifyTarget(params.ctx);
|
||||
if (!target) {
|
||||
return { text: "Could not resolve Telegram target for this chat." };
|
||||
}
|
||||
|
||||
const stateDir = params.api.runtime.state.resolveStateDir();
|
||||
const statePath = resolveNotifyStatePath(stateDir);
|
||||
const state = await readNotifyState(statePath);
|
||||
const targetKey = notifySubscriberKey(target);
|
||||
const current = state.subscribers.find((entry) => notifySubscriberKey(entry) === targetKey);
|
||||
|
||||
if (params.action === "on" || params.action === "enable") {
|
||||
if (upsertNotifySubscriber(state.subscribers, target, "persistent")) {
|
||||
await writeNotifyState(statePath, state);
|
||||
}
|
||||
return {
|
||||
text:
|
||||
"✅ Pair request notifications enabled for this Telegram chat.\n" +
|
||||
"I will ping here when a new device pairing request arrives.",
|
||||
};
|
||||
}
|
||||
|
||||
if (params.action === "off" || params.action === "disable") {
|
||||
const currentIndex = state.subscribers.findIndex(
|
||||
(entry) => notifySubscriberKey(entry) === targetKey,
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
state.subscribers.splice(currentIndex, 1);
|
||||
await writeNotifyState(statePath, state);
|
||||
}
|
||||
return { text: "✅ Pair request notifications disabled for this Telegram chat." };
|
||||
}
|
||||
|
||||
if (params.action === "once" || params.action === "arm") {
|
||||
await armPairNotifyOnce({
|
||||
api: params.api,
|
||||
ctx: params.ctx,
|
||||
});
|
||||
return {
|
||||
text:
|
||||
"✅ One-shot pairing notification armed for this Telegram chat.\n" +
|
||||
"I will notify on the next new pairing request, then auto-disable.",
|
||||
};
|
||||
}
|
||||
|
||||
if (params.action === "status" || params.action === "") {
|
||||
const pending = await listDevicePairing();
|
||||
const enabled = Boolean(current);
|
||||
const mode = current?.mode ?? "off";
|
||||
return {
|
||||
text: [
|
||||
`Pair request notifications: ${enabled ? "enabled" : "disabled"} for this chat.`,
|
||||
`Mode: ${mode}`,
|
||||
`Subscribers: ${state.subscribers.length}`,
|
||||
`Pending requests: ${pending.pending.length}`,
|
||||
"",
|
||||
"Use /pair notify on|off|once",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { text: "Usage: /pair notify on|off|once|status" };
|
||||
}
|
||||
|
||||
export function registerPairingNotifierService(api: OpenClawPluginApi): void {
|
||||
let notifyInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
api.registerService({
|
||||
id: "device-pair-notifier",
|
||||
start: async (ctx) => {
|
||||
const statePath = resolveNotifyStatePath(ctx.stateDir);
|
||||
const tick = async () => {
|
||||
await notifyPendingPairingRequests({ api, statePath });
|
||||
};
|
||||
|
||||
await tick().catch((err) => {
|
||||
api.logger.warn(
|
||||
`device-pair: initial notify poll failed: ${String((err as Error)?.message ?? err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
notifyInterval = setInterval(() => {
|
||||
tick().catch((err) => {
|
||||
api.logger.warn(
|
||||
`device-pair: notify poll failed: ${String((err as Error)?.message ?? err)}`,
|
||||
);
|
||||
});
|
||||
}, NOTIFY_POLL_INTERVAL_MS);
|
||||
notifyInterval.unref?.();
|
||||
},
|
||||
stop: async () => {
|
||||
if (notifyInterval) {
|
||||
clearInterval(notifyInterval);
|
||||
notifyInterval = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { createDiagnosticsOtelService } from "./src/service.js";
|
||||
|
||||
const plugin = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
diffsPluginConfigSchema,
|
||||
resolveDiffsPluginDefaults,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/discord";
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "./channel.js";
|
||||
import { setDiscordRuntime } from "./runtime.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDiscordSubagentHooks } from "./subagent-hooks.js";
|
||||
|
||||
@@ -35,7 +35,7 @@ const hookMocks = vi.hoisted(() => ({
|
||||
unbindThreadBindingsBySessionKey: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/discord", () => ({
|
||||
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
|
||||
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord";
|
||||
import {
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey,
|
||||
resolveDiscordAccount,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
|
||||
function summarizeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { registerFeishuBitableTools } from "./src/bitable.js";
|
||||
import { feishuPlugin } from "./src/channel.js";
|
||||
import { registerFeishuChatTools } from "./src/chat.js";
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveDefaultFeishuAccountSelection,
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuCredentials,
|
||||
} from "./accounts.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
const asConfig = (value: Partial<FeishuConfig>) => value as FeishuConfig;
|
||||
|
||||
describe("resolveDefaultFeishuAccountId", () => {
|
||||
it("prefers channels.feishu.defaultAccount when configured", () => {
|
||||
@@ -98,6 +102,148 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuCredentials", () => {
|
||||
it("throws unresolved SecretRef errors by default for unsupported secret sources", () => {
|
||||
expect(() =>
|
||||
resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
|
||||
}),
|
||||
),
|
||||
).toThrow(/unresolved SecretRef/i);
|
||||
});
|
||||
|
||||
it("returns null (without throwing) when unresolved SecretRef is allowed", () => {
|
||||
const creds = resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "file", provider: "default", id: "path/to/secret" } as never,
|
||||
}),
|
||||
{ allowUnresolvedSecretRef: true },
|
||||
);
|
||||
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => {
|
||||
const key = "FEISHU_APP_SECRET_MISSING_TEST";
|
||||
const prev = process.env[key];
|
||||
delete process.env[key];
|
||||
try {
|
||||
expect(() =>
|
||||
resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "env", provider: "default", id: key } as never,
|
||||
}),
|
||||
),
|
||||
).toThrow(/unresolved SecretRef/i);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
|
||||
const key = "FEISHU_APP_SECRET_TEST";
|
||||
const prev = process.env[key];
|
||||
process.env[key] = " secret_from_env ";
|
||||
|
||||
try {
|
||||
const creds = resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "env", provider: "default", id: key } as never,
|
||||
}),
|
||||
{ allowUnresolvedSecretRef: true },
|
||||
);
|
||||
|
||||
expect(creds).toEqual({
|
||||
appId: "cli_123",
|
||||
appSecret: "secret_from_env",
|
||||
encryptKey: undefined,
|
||||
verificationToken: undefined,
|
||||
domain: "feishu",
|
||||
});
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves env SecretRef with custom provider alias when unresolved refs are allowed", () => {
|
||||
const key = "FEISHU_APP_SECRET_CUSTOM_PROVIDER_TEST";
|
||||
const prev = process.env[key];
|
||||
process.env[key] = " secret_from_env_alias ";
|
||||
|
||||
try {
|
||||
const creds = resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "env", provider: "corp-env", id: key } as never,
|
||||
}),
|
||||
{ allowUnresolvedSecretRef: true },
|
||||
);
|
||||
|
||||
expect(creds?.appSecret).toBe("secret_from_env_alias");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => {
|
||||
const key = "FEISHU_APP_SECRET_POLICY_TEST";
|
||||
const prev = process.env[key];
|
||||
process.env[key] = "secret_from_env";
|
||||
try {
|
||||
expect(() =>
|
||||
resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "env", provider: "default", id: key } as never,
|
||||
}),
|
||||
),
|
||||
).toThrow(/unresolved SecretRef/i);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("trims and returns credentials when values are valid strings", () => {
|
||||
const creds = resolveFeishuCredentials(
|
||||
asConfig({
|
||||
appId: " cli_123 ",
|
||||
appSecret: " secret_456 ",
|
||||
encryptKey: " enc ",
|
||||
verificationToken: " vt ",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(creds).toEqual({
|
||||
appId: "cli_123",
|
||||
appSecret: "secret_456",
|
||||
encryptKey: "enc",
|
||||
verificationToken: "vt",
|
||||
domain: "feishu",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAccount", () => {
|
||||
it("uses top-level credentials with configured default account id even without account map entry", () => {
|
||||
const cfg = {
|
||||
@@ -158,4 +304,45 @@ describe("resolveFeishuAccount", () => {
|
||||
expect(account.selectionSource).toBe("explicit");
|
||||
expect(account.appId).toBe("cli_default");
|
||||
});
|
||||
|
||||
it("surfaces unresolved SecretRef errors in account resolution", () => {
|
||||
expect(() =>
|
||||
resolveFeishuAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_123",
|
||||
appSecret: { source: "file", provider: "default", id: "path/to/secret" },
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "main",
|
||||
}),
|
||||
).toThrow(/unresolved SecretRef/i);
|
||||
});
|
||||
|
||||
it("does not throw when account name is non-string", () => {
|
||||
expect(() =>
|
||||
resolveFeishuAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
main: {
|
||||
name: { bad: true },
|
||||
appId: "cli_123",
|
||||
appSecret: "secret_456",
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "main",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,27 +129,54 @@ export function resolveFeishuCredentials(
|
||||
verificationToken?: string;
|
||||
domain: FeishuDomain;
|
||||
} | null {
|
||||
const appId = cfg?.appId?.trim();
|
||||
const appSecret = options?.allowUnresolvedSecretRef
|
||||
? normalizeSecretInputString(cfg?.appSecret)
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: cfg?.appSecret,
|
||||
path: "channels.feishu.appSecret",
|
||||
});
|
||||
const normalizeString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const resolveSecretLike = (value: unknown, path: string): string | undefined => {
|
||||
const asString = normalizeString(value);
|
||||
if (asString) {
|
||||
return asString;
|
||||
}
|
||||
|
||||
// In relaxed/onboarding paths only: allow direct env SecretRef reads for UX.
|
||||
// Default resolution path must preserve unresolved-ref diagnostics/policy semantics.
|
||||
if (options?.allowUnresolvedSecretRef && typeof value === "object" && value !== null) {
|
||||
const rec = value as Record<string, unknown>;
|
||||
const source = normalizeString(rec.source)?.toLowerCase();
|
||||
const id = normalizeString(rec.id);
|
||||
if (source === "env" && id) {
|
||||
const envValue = normalizeString(process.env[id]);
|
||||
if (envValue) {
|
||||
return envValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.allowUnresolvedSecretRef) {
|
||||
return normalizeSecretInputString(value);
|
||||
}
|
||||
return normalizeResolvedSecretInputString({ value, path });
|
||||
};
|
||||
|
||||
const appId = resolveSecretLike(cfg?.appId, "channels.feishu.appId");
|
||||
const appSecret = resolveSecretLike(cfg?.appSecret, "channels.feishu.appSecret");
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
encryptKey: cfg?.encryptKey?.trim() || undefined,
|
||||
verificationToken:
|
||||
(options?.allowUnresolvedSecretRef
|
||||
? normalizeSecretInputString(cfg?.verificationToken)
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: cfg?.verificationToken,
|
||||
path: "channels.feishu.verificationToken",
|
||||
})) || undefined,
|
||||
encryptKey: normalizeString(cfg?.encryptKey),
|
||||
verificationToken: resolveSecretLike(
|
||||
cfg?.verificationToken,
|
||||
"channels.feishu.verificationToken",
|
||||
),
|
||||
domain: cfg?.domain ?? "feishu",
|
||||
};
|
||||
}
|
||||
@@ -186,13 +213,14 @@ export function resolveFeishuAccount(params: {
|
||||
|
||||
// Resolve credentials from merged config
|
||||
const creds = resolveFeishuCredentials(merged);
|
||||
const accountName = (merged as FeishuAccountConfig).name;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
selectionSource,
|
||||
enabled,
|
||||
configured: Boolean(creds),
|
||||
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
|
||||
name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
|
||||
appId: creds?.appId,
|
||||
appSecret: creds?.appSecret,
|
||||
encryptKey: creds?.encryptKey,
|
||||
|
||||
@@ -77,9 +77,12 @@ describe("createFeishuWSClient proxy handling", () => {
|
||||
expect(options?.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers HTTPS proxy vars over HTTP proxy vars across runtimes", () => {
|
||||
it("uses proxy env precedence: https_proxy first, then HTTPS_PROXY, then http_proxy/HTTP_PROXY", () => {
|
||||
// NOTE: On Windows, environment variables are case-insensitive, so it's not
|
||||
// possible to set both https_proxy and HTTPS_PROXY to different values.
|
||||
// Keep this test cross-platform by asserting precedence via mutually-exclusive
|
||||
// setups.
|
||||
process.env.https_proxy = "http://lower-https:8001";
|
||||
process.env.HTTPS_PROXY = "http://upper-https:8002";
|
||||
process.env.http_proxy = "http://lower-http:8003";
|
||||
process.env.HTTP_PROXY = "http://upper-http:8004";
|
||||
|
||||
@@ -108,6 +111,18 @@ describe("createFeishuWSClient proxy handling", () => {
|
||||
expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
|
||||
});
|
||||
|
||||
it("uses HTTPS_PROXY when https_proxy is unset", () => {
|
||||
process.env.HTTPS_PROXY = "http://upper-https:8002";
|
||||
process.env.http_proxy = "http://lower-http:8003";
|
||||
|
||||
createFeishuWSClient(baseAccount);
|
||||
|
||||
expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith("http://upper-https:8002");
|
||||
const options = firstWsClientOptions();
|
||||
expect(options.agent).toEqual({ proxyUrl: "http://upper-https:8002" });
|
||||
});
|
||||
|
||||
it("passes HTTP_PROXY to ws client when https vars are unset", () => {
|
||||
process.env.HTTP_PROXY = "http://upper-http:8999";
|
||||
|
||||
|
||||
147
extensions/feishu/src/onboarding.test.ts
Normal file
147
extensions/feishu/src/onboarding.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
|
||||
}));
|
||||
|
||||
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
const baseConfigureContext = {
|
||||
runtime: {} as never,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
forceAllowFrom: false,
|
||||
};
|
||||
|
||||
const baseStatusContext = {
|
||||
accountOverrides: {},
|
||||
};
|
||||
|
||||
describe("feishuOnboardingAdapter.configure", () => {
|
||||
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
|
||||
const text = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("cli_from_prompt")
|
||||
.mockResolvedValueOnce("secret_from_prompt")
|
||||
.mockResolvedValueOnce("oc_group_1");
|
||||
|
||||
const prompter = {
|
||||
note: vi.fn(async () => undefined),
|
||||
text,
|
||||
confirm: vi.fn(async () => true),
|
||||
select: vi.fn(
|
||||
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
|
||||
),
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
feishuOnboardingAdapter.configure({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", id: "FEISHU_APP_ID", provider: "default" },
|
||||
appSecret: { source: "env", id: "FEISHU_APP_SECRET", provider: "default" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
prompter,
|
||||
...baseConfigureContext,
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishuOnboardingAdapter.getStatus", () => {
|
||||
it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
|
||||
const status = await feishuOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "top_level_app",
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "",
|
||||
appSecret: "secret_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
...baseStatusContext,
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("treats env SecretRef appId as not configured when env var is missing", async () => {
|
||||
const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST";
|
||||
const appSecretKey = "FEISHU_APP_SECRET_STATUS_MISSING_TEST";
|
||||
const prevAppId = process.env[appIdKey];
|
||||
const prevAppSecret = process.env[appSecretKey];
|
||||
delete process.env[appIdKey];
|
||||
process.env[appSecretKey] = "secret_env_456";
|
||||
|
||||
try {
|
||||
const status = await feishuOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", id: appIdKey, provider: "default" },
|
||||
appSecret: { source: "env", id: appSecretKey, provider: "default" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
...baseStatusContext,
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
} finally {
|
||||
if (prevAppId === undefined) {
|
||||
delete process.env[appIdKey];
|
||||
} else {
|
||||
process.env[appIdKey] = prevAppId;
|
||||
}
|
||||
if (prevAppSecret === undefined) {
|
||||
delete process.env[appSecretKey];
|
||||
} else {
|
||||
process.env[appSecretKey] = prevAppSecret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats env SecretRef appId/appSecret as configured in status", async () => {
|
||||
const appIdKey = "FEISHU_APP_ID_STATUS_TEST";
|
||||
const appSecretKey = "FEISHU_APP_SECRET_STATUS_TEST";
|
||||
const prevAppId = process.env[appIdKey];
|
||||
const prevAppSecret = process.env[appSecretKey];
|
||||
process.env[appIdKey] = "cli_env_123";
|
||||
process.env[appSecretKey] = "secret_env_456";
|
||||
|
||||
try {
|
||||
const status = await feishuOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: { source: "env", id: appIdKey, provider: "default" },
|
||||
appSecret: { source: "env", id: appSecretKey, provider: "default" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
...baseStatusContext,
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
} finally {
|
||||
if (prevAppId === undefined) {
|
||||
delete process.env[appIdKey];
|
||||
} else {
|
||||
process.env[appIdKey] = prevAppId;
|
||||
}
|
||||
if (prevAppSecret === undefined) {
|
||||
delete process.env[appSecretKey];
|
||||
} else {
|
||||
process.env[appSecretKey] = prevAppSecret;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ import type { FeishuConfig } from "./types.js";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
@@ -169,20 +177,43 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
const isAppIdConfigured = (value: unknown): boolean => {
|
||||
const asString = normalizeString(value);
|
||||
if (asString) {
|
||||
return true;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const rec = value as Record<string, unknown>;
|
||||
const source = normalizeString(rec.source)?.toLowerCase();
|
||||
const id = normalizeString(rec.id);
|
||||
if (source === "env" && id) {
|
||||
return Boolean(normalizeString(process.env[id]));
|
||||
}
|
||||
return hasConfiguredSecretInput(value);
|
||||
};
|
||||
|
||||
const topLevelConfigured = Boolean(
|
||||
feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret),
|
||||
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
|
||||
);
|
||||
|
||||
const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
|
||||
if (!account || typeof account !== "object") {
|
||||
return false;
|
||||
}
|
||||
const accountAppId =
|
||||
typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim();
|
||||
const accountSecretConfigured =
|
||||
hasConfiguredSecretInput(account.appSecret) ||
|
||||
hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
return Boolean(accountAppId && accountSecretConfigured);
|
||||
const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
|
||||
const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
|
||||
const accountAppIdConfigured = hasOwnAppId
|
||||
? isAppIdConfigured((account as Record<string, unknown>).appId)
|
||||
: isAppIdConfigured(feishuCfg?.appId);
|
||||
const accountSecretConfigured = hasOwnAppSecret
|
||||
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
|
||||
: hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
return Boolean(accountAppIdConfigured && accountSecretConfigured);
|
||||
});
|
||||
|
||||
const configured = topLevelConfigured || accountConfigured;
|
||||
const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
|
||||
allowUnresolvedSecretRef: true,
|
||||
@@ -224,7 +255,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
|
||||
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret);
|
||||
const hasConfigCreds = Boolean(
|
||||
typeof feishuCfg?.appId === "string" && feishuCfg.appId.trim() && hasConfigSecret,
|
||||
);
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
|
||||
);
|
||||
@@ -265,7 +298,8 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
appSecretProbeValue = appSecretResult.resolvedValue;
|
||||
appId = await promptFeishuAppId({
|
||||
prompter,
|
||||
initialValue: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(),
|
||||
initialValue:
|
||||
normalizeString(feishuCfg?.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,11 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
||||
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
|
||||
const trimmed = id.trim();
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("chat:") || lowered.startsWith("group:")) {
|
||||
if (
|
||||
lowered.startsWith("chat:") ||
|
||||
lowered.startsWith("group:") ||
|
||||
lowered.startsWith("channel:")
|
||||
) {
|
||||
return "chat_id";
|
||||
}
|
||||
if (lowered.startsWith("open_id:")) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
emptyPluginConfigSchema,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
|
||||
import { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.1"
|
||||
"openclaw": ">=2026.3.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/imessage";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/imessage";
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/imessage";
|
||||
import { getIMessageRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("imessage");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { ircPlugin } from "./src/channel.js";
|
||||
import { setIrcRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/line";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/line";
|
||||
import { registerLineCardCommand } from "./src/card-command.js";
|
||||
import { linePlugin } from "./src/channel.js";
|
||||
import { setLineRuntime } from "./src/runtime.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "openclaw/plugin-sdk";
|
||||
import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "openclaw/plugin-sdk/line";
|
||||
import {
|
||||
createActionCard,
|
||||
createImageCard,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
createReceiptCard,
|
||||
type CardAction,
|
||||
type ListItem,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/line";
|
||||
|
||||
const CARD_USAGE = `Usage: /card <type> "title" "body" [options]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk/line";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
ResolvedLineAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/line";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type LineConfig,
|
||||
type LineChannelData,
|
||||
type ResolvedLineAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/line";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
|
||||
// LINE channel metadata
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
import { getSlashCommandState, registerSlashCommandRoute } from "./src/mattermost/slash-state.js";
|
||||
import { setMattermostRuntime } from "./src/runtime.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.1"
|
||||
"openclaw": ">=2026.3.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type * as LanceDB from "@lancedb/lancedb";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import OpenAI from "openai";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
DEFAULT_CAPTURE_MAX_CHARS,
|
||||
MEMORY_CATEGORIES,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "minimax-portal";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ const expressControl = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
|
||||
normalizeSecretInputString: (value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : undefined,
|
||||
hasConfiguredSecretInput: (value: unknown) =>
|
||||
typeof value === "string" && value.trim().length > 0,
|
||||
normalizeResolvedSecretInputString: (params: { value?: unknown }) =>
|
||||
typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined,
|
||||
keepHttpServerTaskAlive: vi.fn(
|
||||
async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { nextcloudTalkPlugin } from "./src/channel.js";
|
||||
import { setNextcloudTalkRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { nostrPlugin } from "./src/channel.js";
|
||||
import type { NostrProfile } from "./src/config-schema.js";
|
||||
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi, OpenClawPluginService } from "openclaw/plugin-sdk/core";
|
||||
|
||||
type ArmGroup = "camera" | "screen" | "writes" | "all";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
emptyPluginConfigSchema,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { loginQwenPortalOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "qwen-portal";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/signal";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/signal";
|
||||
import { signalPlugin } from "./src/channel.js";
|
||||
import { setSignalRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/signal";
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/slack";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/slack";
|
||||
import { slackPlugin } from "./src/channel.js";
|
||||
import { setSlackRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/slack";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const handleSlackActionMock = vi.fn();
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SlackConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSlackAccount,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/slack";
|
||||
import { getSlackRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("slack");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { createSynologyChatPlugin } from "./src/channel.js";
|
||||
import { setSynologyRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
|
||||
type ElevenLabsVoice = {
|
||||
voice_id: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { telegramPlugin } from "./src/channel.js";
|
||||
import { setTelegramRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type OpenClawConfig,
|
||||
type ResolvedTelegramAccount,
|
||||
type TelegramProbe,
|
||||
} from "openclaw/plugin-sdk";
|
||||
} from "openclaw/plugin-sdk/telegram";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
|
||||
const meta = getChatChannelMeta("telegram");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
|
||||
type ThreadOwnershipConfig = {
|
||||
forwarderUrl?: string;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { tlonPlugin } from "./src/channel.js";
|
||||
import { setTlonRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { twitchPlugin } from "./src/plugin.js";
|
||||
import { setTwitchRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { registerVoiceCallCli } from "./src/cli.js";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/whatsapp";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/whatsapp";
|
||||
import { whatsappPlugin } from "./src/channel.js";
|
||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user