mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
10 Commits
fix/35209-
...
stack/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c551ac48 | ||
|
|
4ad8bb8630 | ||
|
|
e0fd16fb61 | ||
|
|
64ce1a11d4 | ||
|
|
2d676d6460 | ||
|
|
2e90bc3d7d | ||
|
|
cafb5c8e12 | ||
|
|
253aec92d1 | ||
|
|
78b7a72510 | ||
|
|
7f682a747d |
2
.github/actions/setup-node-env/action.yml
vendored
2
.github/actions/setup-node-env/action.yml
vendored
@@ -61,7 +61,7 @@ runs:
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
bun-version: "1.3.9+cf6cdbbba"
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- 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
|
||||
@@ -76,8 +75,6 @@
|
||||
- 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.
|
||||
|
||||
100
CHANGELOG.md
100
CHANGELOG.md
@@ -6,108 +6,20 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Web UI/i18n: add Spanish (`es`) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.
|
||||
- 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/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.
|
||||
- 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.
|
||||
- Telegram/topic agent routing: support per-topic `agentId` overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
|
||||
- Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
|
||||
- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
||||
- Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
|
||||
- Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&`, `"`, `<`, `>`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
|
||||
- Agents/thinking-tag promotion hardening: guard `promoteThinkingTagsToBlocks` against malformed assistant content entries (`null`/`undefined`) before `block.type` reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
|
||||
- Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid `dev` placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap `serverVersion` to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
|
||||
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
|
||||
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
|
||||
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.
|
||||
- Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.
|
||||
- Auto-reply/system events: restore runtime system events to the message timeline (`System:` lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.
|
||||
- Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for `accounts`. (#34982) Thanks @HOYALIM.
|
||||
- Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.
|
||||
- Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session `totalTokens` from real usage instead of stale prior values. (#34275) thanks @RealKai42.
|
||||
- Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared `rawCommand`, and cover the `system.run.prepare -> system.run` handoff so direct PATH-based `nodes.run` commands no longer fail with `rawCommand does not match command`. (#33137) thanks @Sid-Qin.
|
||||
- Models/custom provider headers: propagate `models.providers.<name>.headers` across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.
|
||||
- Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.
|
||||
- Daemon/systemd install robustness: treat `systemctl --user is-enabled` exit-code-4 `not-found` responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with `systemctl is-enabled unavailable`. (#33634) Thanks @Yuandiaodiaodiao.
|
||||
- Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.
|
||||
- Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct `/tools/invoke` clients by allowing media `nodes` invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.
|
||||
- Agents/Nodes media outputs: add dedicated `photos_latest` action handling, block media-returning `nodes invoke` commands, keep metadata-only `camera.list` invoke allowed, and normalize empty `photos_latest` results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.
|
||||
- TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.
|
||||
- Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
|
||||
- Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
|
||||
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
|
||||
- ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.
|
||||
- Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
|
||||
- Feishu/video media send contract: keep mp4-like outbound payloads on `msg_type: "media"` (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.
|
||||
- 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.
|
||||
- Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root `openclaw/plugin-sdk` compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.
|
||||
- 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: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.
|
||||
- Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic `openclaw/plugin-sdk` imports to scoped subpaths (or `openclaw/plugin-sdk/core`) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root `openclaw/plugin-sdk` support for external/community plugins. Thanks @gumadeiras.
|
||||
- Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.
|
||||
- Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (`agent:<agent>:<channel>:<peer>` and `...:thread:<id>`) so `chat.send` does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.
|
||||
- Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like `agent:<agent>:work:<ticket>` from inheriting stale non-webchat routes.
|
||||
- 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/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.
|
||||
- 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/DM draft final delivery: materialize text-only `sendMessageDraft` previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.
|
||||
- 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.
|
||||
- Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement `sendText` (without `sendMedia`) to remain outbound-capable, gracefully fall back to text delivery for media payloads when `sendMedia` is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.
|
||||
- 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.
|
||||
- CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.
|
||||
- ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.
|
||||
- ACP/sessions_spawn parent stream visibility: add `streamTo: "parent"` for `runtime: "acp"` to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (`<sessionId>.acp-stream.jsonl`, returned as `streamLogPath` when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.
|
||||
- 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 template heading alignment: update AGENTS template section names to `Session Startup`/`Red Lines` and keep legacy `Every Session`/`Safety` fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.
|
||||
- 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.
|
||||
- Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.
|
||||
- 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.
|
||||
- Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed `embedQuery` + `embedBatch` concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.
|
||||
- CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.
|
||||
- ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.
|
||||
- LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
|
||||
- LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
|
||||
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
|
||||
@@ -209,7 +121,6 @@ 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.
|
||||
@@ -426,8 +337,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Daemon/Linux systemd user-bus fallback: when `systemctl --user` cannot reach the user bus due missing session env, fall back to `systemctl --machine <user>@ --user` so daemon checks/install continue in headless SSH/server sessions. (#34884) Thanks @vincentkoc.
|
||||
- Gateway/Linux restart health: reduce false `openclaw gateway restart` timeouts by falling back to `ss -ltnp` when `lsof` is missing, confirming ambiguous busy-port cases via local gateway probe, and targeting the original `SUDO_USER` systemd user scope for restart commands. (#34874) Thanks @vincentkoc.
|
||||
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
|
||||
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.
|
||||
@@ -448,7 +357,6 @@ 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.
|
||||
@@ -457,13 +365,8 @@ 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).
|
||||
@@ -583,7 +486,6 @@ 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.
|
||||
@@ -989,7 +891,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 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.
|
||||
- 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.
|
||||
- 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,7 +74,6 @@ 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
|
||||
|
||||
|
||||
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# OpenClaw iOS App - Comprehensive Audit Report 2026
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~16,244 LOC), `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0 | Swift 6.0 (strict concurrency: complete)
|
||||
**Audit Team:** 5 specialized Opus 4.6 agents (Concurrency, API Modernization, Architecture, UI/UX, Security)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS app is a **well-engineered codebase** that has adopted many 2026 best practices: Swift 6 strict concurrency, the Observation framework (`@Observable`), `NavigationStack`, Keychain credential storage, and TLS certificate pinning. However, the audit identified **9 critical findings**, **17 high findings**, **29 medium findings**, and **25 low findings** across 5 audit domains.
|
||||
|
||||
### Overall Health Score: **B+** (74/100)
|
||||
|
||||
| Domain | Score | Grade | Key Issue |
|
||||
|--------|-------|-------|-----------|
|
||||
| Swift 6 Concurrency | 78/100 | B+ | 3 data race risks, 5 unsafe patterns |
|
||||
| iOS 26 API Modernization | 82/100 | A- | 1 deprecated framework, 4 dead code paths |
|
||||
| Architecture & Code Quality | 62/100 | C+ | 2 god objects, 11.6% test coverage ratio |
|
||||
| UI/UX & Accessibility | 65/100 | C+ | Zero Dynamic Type, zero localization |
|
||||
| Security & Performance | 85/100 | A | No critical vulns, 3 high storage issues |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings (9)
|
||||
|
||||
### Concurrency (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| CON-C1 | `GatewayTLSFingerprintProbe` data race: `objc_sync_enter` with unsynchronized `didFinish`/`session`/`task` reads in `start()` | `Gateway/GatewayConnectionController.swift:992-1058` | Crash/undefined behavior |
|
||||
| CON-C2 | `PhotoCaptureDelegate` & `MovieFileDelegate` unsynchronized `didResume` flag can double-resume `CheckedContinuation` | `Camera/CameraController.swift:260-339` | Fatal crash (debug), UB (release) |
|
||||
| CON-C3 | `GatewayDiagnostics.logWritesSinceCheck` uses `nonisolated(unsafe)` suppressing all compiler race checks | `Gateway/GatewaySettingsStore.swift:358` | Silent data race |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| ARC-C1 | `NodeAppModel` is a 2,787 LOC god object with ~17 responsibilities | `Model/NodeAppModel.swift` | Untestable, unmaintainable |
|
||||
| ARC-C2 | `TalkModeManager` is a 2,153 LOC god object centralizing speech, audio, PTT, and gateway comms | `Voice/TalkModeManager.swift` | Same as above |
|
||||
|
||||
### UI/UX (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| UIX-C1 | `RootCanvas` voiceWakeToast animations ignore `accessibilityReduceMotion` | `RootCanvas.swift:159-167` | Accessibility violation |
|
||||
| UIX-C2 | `TalkOrbOverlay` perpetual pulse animations ignore `accessibilityReduceMotion` | `Voice/TalkOrbOverlay.swift:15-26` | Vestibular disorder risk |
|
||||
| UIX-C3 | `CameraFlashOverlay` has no VoiceOver announcement and no reduced motion check | `RootCanvas.swift:405-429` | Accessibility violation, photosensitivity |
|
||||
|
||||
### API Modernization (1)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| API-C1 | `NetService` usage (deprecated since iOS 16, removed in future SDKs) while `NWBrowser` already used for discovery | `Gateway/GatewayServiceResolver.swift`, `Gateway/GatewayConnectionController.swift:560-657` | Future SDK breakage |
|
||||
|
||||
---
|
||||
|
||||
## High Findings (17)
|
||||
|
||||
### Concurrency (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| CON-H1 | `ScreenRecordService` `UncheckedSendableBox<T>` wraps any T as Sendable, silencing compiler | `Screen/ScreenRecordService.swift:4-11` |
|
||||
| CON-H2 | `WatchMessagingService` `@unchecked Sendable` with `WCSession` property reads unprotected | `Services/WatchMessagingService.swift:23-28` |
|
||||
| CON-H3 | `LocationService` stores `CheckedContinuation` as instance vars with `nonisolated` delegate callbacks hopping to `@MainActor` | `Location/LocationService.swift:13-14` |
|
||||
| CON-H4 | `LiveNotificationCenter` wraps non-Sendable `UNUserNotificationCenter` in `@unchecked Sendable` | `Services/NotificationService.swift:18-58` |
|
||||
| CON-H5 | `NetworkStatusService` is `@unchecked Sendable` but stateless - unnecessary annotation | `Device/NetworkStatusService.swift:5` |
|
||||
|
||||
### Security (3)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| SEC-H1 | TLS fingerprints stored in UserDefaults (backup-extractable trust anchor) | `OpenClawKit/GatewayTLSPinning.swift:19-38` |
|
||||
| SEC-H2 | `KeychainStore` update path doesn't enforce `kSecAttrAccessible` on existing items | `Gateway/KeychainStore.swift:20-37` |
|
||||
| SEC-H3 | Gateway connection metadata (host/port/topology) in UserDefaults | `Gateway/GatewaySettingsStore.swift:170-217` |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| ARC-H1 | 3 oversized files: `GatewayConnectionController` (1,058 LOC), `SettingsTab` (1,032 LOC), `OnboardingWizardView` (884 LOC) | Various |
|
||||
| ARC-H2 | 17 source modules with zero test coverage; 11.6% test LOC ratio | See gap analysis |
|
||||
|
||||
### UI/UX (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| UIX-H1 | Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize`) | All view files |
|
||||
| UIX-H2 | Zero localization infrastructure (all hardcoded English) | All source files |
|
||||
| UIX-H3 | Zero haptic feedback in entire app | All source files |
|
||||
| UIX-H4 | OnboardingWizardView missing accessibility labels on mode selection rows | `Onboarding/OnboardingWizardView.swift` |
|
||||
| UIX-H5 | `GatewayTrustPromptAlert` and `DeepLinkAgentPromptAlert` use deprecated `Alert` API | `Gateway/GatewayTrustPromptAlert.swift` |
|
||||
|
||||
### API Modernization (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| API-H1 | Dead `#available(iOS 15/18)` checks (deployment target is iOS 18.0) | `OpenClawApp.swift:344`, `Camera/CameraController.swift:222-249` |
|
||||
| API-H2 | `UNUserNotificationCenter` callback APIs wrapped in continuations instead of native async | `OpenClawApp.swift:429-462` |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Themes
|
||||
|
||||
### 1. God Object Pattern
|
||||
`NodeAppModel` (2,787 LOC) and `TalkModeManager` (2,153 LOC) together represent **30%** of the entire codebase. Both have `// swiftlint:disable` suppressions acknowledging the problem. This is the single highest-impact improvement opportunity.
|
||||
|
||||
### 2. Inconsistent Synchronization Primitives
|
||||
The codebase uses 4 different synchronization mechanisms: `NSLock` (6 usages), `OSAllocatedUnfairLock` (1 usage), `objc_sync_enter/exit` (1 usage), and `DispatchQueue` serialization (7 usages). Standardizing on `OSAllocatedUnfairLock` + actors would improve consistency and safety.
|
||||
|
||||
### 3. UserDefaults Overuse
|
||||
~70+ direct `UserDefaults.standard` reads/writes with raw string keys across the codebase. TLS fingerprints, gateway metadata, and connection details stored in UserDefaults should be in Keychain. Non-sensitive preferences lack a typed key registry.
|
||||
|
||||
### 4. Missing Accessibility Infrastructure
|
||||
Dynamic Type, localization, and haptic feedback are completely absent. Three views ignore `accessibilityReduceMotion`. This represents the largest gap relative to Apple's 2026 HIG expectations.
|
||||
|
||||
### 5. Test Coverage Gaps
|
||||
11.6% test LOC ratio with 17 untested modules. The gateway reconnect state machine (most complex logic), background lifecycle, onboarding flow, and TalkModeManager have minimal or zero test coverage.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis (Top 15 Gaps)
|
||||
|
||||
| Module | Source LOC | Test LOC | Coverage |
|
||||
|--------|-----------|----------|----------|
|
||||
| `NodeAppModel.swift` | 2,787 | 478 (invoke only) | Partial - reconnect/background/deep links untested |
|
||||
| `TalkModeManager.swift` | 2,153 | 31 (config only) | Minimal |
|
||||
| `GatewayConnectionController.swift` | 1,058 | 226 | Partial - no TLS/Bonjour/autoconnect tests |
|
||||
| `SettingsTab.swift` | 1,032 | 8 (smoke) | Smoke only |
|
||||
| `OnboardingWizardView.swift` | 884 | 0 | None |
|
||||
| `OpenClawApp.swift` | 541 | 0 | None |
|
||||
| `RootCanvas.swift` | 429 | 8 (smoke) | Smoke only |
|
||||
| `GatewayOnboardingView.swift` | 371 | 0 | None |
|
||||
| `WatchMessagingService.swift` | 284 | 0 | None |
|
||||
| `ContactsService.swift` | 210 | 0 | None |
|
||||
| `LocationService.swift` | 177 | 0 | None |
|
||||
| `PhotoLibraryService.swift` | 164 | 0 | None |
|
||||
| `CalendarService.swift` | 135 | 0 | None |
|
||||
| `RemindersService.swift` | 133 | 0 | None |
|
||||
| `MotionService.swift` | 100 | 0 | None |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 1 | Fix `PhotoCaptureDelegate`/`MovieFileDelegate` `didResume` synchronization (CON-C2) | Small | Prevents crashes |
|
||||
| 2 | Fix `GatewayTLSFingerprintProbe` data race (CON-C1) | Small | Prevents undefined behavior |
|
||||
| 3 | Add `accessibilityReduceMotion` checks to `RootCanvas` and `TalkOrbOverlay` (UIX-C1, C2, C3) | Small | Accessibility compliance |
|
||||
| 4 | Replace `nonisolated(unsafe)` in `GatewayDiagnostics` (CON-C3) | Small | Compiler safety |
|
||||
|
||||
### Phase 2: High-Priority Improvements (Next Sprint)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 5 | Move TLS fingerprints to Keychain (SEC-H1) | Medium | Security hardening |
|
||||
| 6 | Fix `KeychainStore` update accessibility enforcement (SEC-H2) | Small | Security correctness |
|
||||
| 7 | Migrate `NetService` to Network framework (API-C1) | Large | Future-proofing |
|
||||
| 8 | Remove dead `#available` checks (API-H1) | Small | Code cleanup |
|
||||
| 9 | Replace `UNUserNotificationCenter` callbacks with async APIs (API-H2) | Small | Modernization |
|
||||
| 10 | Add `@ScaledMetric` Dynamic Type support to key views (UIX-H1) | Medium | Accessibility |
|
||||
|
||||
### Phase 3: Architecture Refactoring (Planned)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 11 | Split `NodeAppModel` into 5-6 focused types (ARC-C1) | Large | Testability, maintainability |
|
||||
| 12 | Split `TalkModeManager` into 3-4 focused types (ARC-C2) | Large | Same |
|
||||
| 13 | Extract `SettingsTab` into section sub-views (ARC-H1) | Medium | Maintainability |
|
||||
| 14 | Create typed UserDefaults key registry | Medium | Type safety |
|
||||
| 15 | Add test coverage for gateway reconnect state machine | Large | Regression safety |
|
||||
| 16 | Add test coverage for background lifecycle management | Medium | Regression safety |
|
||||
|
||||
### Phase 4: Polish & Hardening (Opportunistic)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 17 | Add localization infrastructure with `String(localized:)` (UIX-H2) | Large | International users |
|
||||
| 18 | Add haptic feedback to key interactions (UIX-H3) | Small | UX polish |
|
||||
| 19 | Standardize on `OSAllocatedUnfairLock` across codebase | Small | Consistency |
|
||||
| 20 | Replace Combine `Timer.publish`/`onReceive` with async patterns | Small | Modernization |
|
||||
| 21 | Add keyboard shortcuts for iPad (UIX-M5) | Small | iPad UX |
|
||||
| 22 | Gate `ELEVENLABS_API_KEY` env var behind `#if DEBUG` (SEC-M3) | Small | Security |
|
||||
| 23 | Enforce minimum interval between deep link prompts (SEC-M5) | Small | Security |
|
||||
| 24 | Add HMAC verification to QR setup codes (SEC-M6) | Medium | Security |
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Observation framework adoption** - Zero `ObservableObject` usage; consistent `@Observable` + `@Environment` throughout
|
||||
2. **Protocol-based DI** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities with default implementations
|
||||
3. **Keychain for credentials** - Tokens, passwords, instance IDs stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
4. **TLS certificate pinning** - TOFU model with SHA-256 fingerprint verification and user confirmation
|
||||
5. **`CameraController` as actor** - Exemplary Swift concurrency pattern for hardware resource management
|
||||
6. **Dual WebSocket sessions** - Node/operator separation provides good privilege scoping
|
||||
7. **Non-persistent WKWebView** - Canvas prevents session data leakage
|
||||
8. **Swift 6 strict concurrency** - Enabled project-wide with `SWIFT_STRICT_CONCURRENCY: complete`
|
||||
9. **`@Sendable` service protocols** - All service protocols correctly require `Sendable` conformance
|
||||
10. **Deep link confirmation** - Agent deep links require explicit user approval with length limits
|
||||
|
||||
---
|
||||
|
||||
## OWASP Mobile Top 10 Summary
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| M1: Improper Credential Usage | PASS |
|
||||
| M2: Inadequate Supply Chain Security | PASS |
|
||||
| M3: Insecure Authentication/Authorization | PASS |
|
||||
| M4: Insufficient Input/Output Validation | PASS |
|
||||
| M5: Insecure Communication | PASS (note: HTTP allowed in web views) |
|
||||
| M6: Inadequate Privacy Controls | PASS (note: location sent over TLS) |
|
||||
| M7: Insufficient Binary Protections | N/A |
|
||||
| M8: Security Misconfiguration | PASS (notes: H-1, H-3) |
|
||||
| M9: Insecure Data Storage | PASS (notes: H-1, H-3, M-2) |
|
||||
| M10: Insufficient Cryptography | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Reports
|
||||
|
||||
Individual audit reports with full code snippets and line-by-line analysis:
|
||||
|
||||
- [`audit-concurrency.md`](./audit-concurrency.md) - Swift 6 strict concurrency (20 findings)
|
||||
- [`audit-api-modernization.md`](./audit-api-modernization.md) - iOS 26 API modernization (19 findings)
|
||||
- [`audit-architecture.md`](./audit-architecture.md) - Architecture & test coverage (16 findings)
|
||||
- [`audit-uiux.md`](./audit-uiux.md) - UI/UX & accessibility (24 findings)
|
||||
- [`audit-security.md`](./audit-security.md) - Security & performance (18 findings)
|
||||
|
||||
---
|
||||
|
||||
*Generated by OpenClaw iOS Audit Team (5x Opus 4.6 agents) on 2026-03-02*
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenClaw Activity</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260301</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,9 +0,0 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct OpenClawActivityWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
OpenClawLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct OpenClawLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
|
||||
lockScreenView(context: context)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
statusDot(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.statusText)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
trailingView(state: context.state)
|
||||
}
|
||||
} compactLeading: {
|
||||
statusDot(state: context.state)
|
||||
} compactTrailing: {
|
||||
Text(context.state.statusText)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
} minimal: {
|
||||
statusDot(state: context.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
statusDot(state: context.state)
|
||||
.frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
.font(.subheadline.bold())
|
||||
Text(context.state.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
trailingView(state: context.state)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Text(state.startedAt, style: .timer)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
Circle()
|
||||
.fill(dotColor(state: state))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .gray }
|
||||
if state.isIdle { return .green }
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
|
||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
|
||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
|
||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
|
||||
|
||||
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
||||
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Security
|
||||
|
||||
enum KeychainStore {
|
||||
static func loadString(service: String, account: String) -> String? {
|
||||
GenericPasswordKeychainStore.loadString(service: service, account: account)
|
||||
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)
|
||||
}
|
||||
|
||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||
GenericPasswordKeychainStore.saveString(value, service: service, account: account)
|
||||
// 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
|
||||
}
|
||||
|
||||
static func delete(service: String, account: String) -> Bool {
|
||||
GenericPasswordKeychainStore.delete(service: service, account: account)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@
|
||||
<string>OpenClaw needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Minimal Live Activity lifecycle focused on connection health + stale cleanup.
|
||||
@MainActor
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
private var activityStartDate: Date = .now
|
||||
|
||||
private init() {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
guard let activity = self.currentActivity else { return false }
|
||||
guard activity.activityState == .active else {
|
||||
self.currentActivity = nil
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func startActivity(agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
if self.currentActivity != nil {
|
||||
self.handleConnecting()
|
||||
return
|
||||
}
|
||||
|
||||
let authInfo = ActivityAuthorizationInfo()
|
||||
guard authInfo.areActivitiesEnabled else {
|
||||
self.logger.info("Live Activities disabled; skipping start")
|
||||
return
|
||||
}
|
||||
|
||||
self.activityStartDate = .now
|
||||
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: self.connectingState(), staleDate: nil),
|
||||
pushType: nil)
|
||||
self.currentActivity = activity
|
||||
self.logger.info("started live activity id=\(activity.id, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("failed to start live activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnecting() {
|
||||
self.updateCurrent(state: self.connectingState())
|
||||
}
|
||||
|
||||
func handleReconnect() {
|
||||
self.updateCurrent(state: self.idleState())
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.updateCurrent(state: self.disconnectedState())
|
||||
}
|
||||
|
||||
private func hydrateCurrentAndPruneDuplicates() {
|
||||
let active = Activity<OpenClawActivityAttributes>.activities
|
||||
guard !active.isEmpty else {
|
||||
self.currentActivity = nil
|
||||
return
|
||||
}
|
||||
|
||||
let keeper = active.max { lhs, rhs in
|
||||
lhs.content.state.startedAt < rhs.content.state.startedAt
|
||||
} ?? active[0]
|
||||
|
||||
self.currentActivity = keeper
|
||||
self.activityStartDate = keeper.content.state.startedAt
|
||||
|
||||
let stale = active.filter { $0.id != keeper.id }
|
||||
for activity in stale {
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: self.disconnectedState(), staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrent(state: OpenClawActivityAttributes.ContentState) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private func connectingState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// Shared schema used by iOS app + Live Activity widget extension.
|
||||
struct OpenClawActivityAttributes: ActivityAttributes {
|
||||
var agentName: String
|
||||
var sessionKey: String
|
||||
|
||||
struct ContentState: Codable, Hashable {
|
||||
var statusText: String
|
||||
var isIdle: Bool
|
||||
var isDisconnected: Bool
|
||||
var isConnecting: Bool
|
||||
var startedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension OpenClawActivityAttributes {
|
||||
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
|
||||
}
|
||||
|
||||
extension OpenClawActivityAttributes.ContentState {
|
||||
static let connecting = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: .now)
|
||||
|
||||
static let idle = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let disconnected = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Disconnected",
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
}
|
||||
#endif
|
||||
@@ -90,9 +90,7 @@ 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()
|
||||
@@ -1695,7 +1693,6 @@ extension NodeAppModel {
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
@@ -1732,7 +1729,6 @@ private extension NodeAppModel {
|
||||
self.operatorConnected = false
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
@@ -1813,7 +1809,6 @@ private extension NodeAppModel {
|
||||
await self.refreshAgentsFromGateway()
|
||||
await self.refreshShareRouteFromGateway()
|
||||
await self.startVoiceWakeSync()
|
||||
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
@@ -1821,7 +1816,6 @@ private extension NodeAppModel {
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
}
|
||||
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
|
||||
await MainActor.run { self.stopGatewayHealthMonitor() }
|
||||
@@ -1886,14 +1880,6 @@ private extension NodeAppModel {
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
let liveActivity = LiveActivityManager.shared
|
||||
if liveActivity.isActive {
|
||||
liveActivity.handleConnecting()
|
||||
} else {
|
||||
liveActivity.startActivity(
|
||||
agentName: self.selectedAgentId ?? "main",
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -2605,31 +2591,19 @@ 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))
|
||||
|
||||
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.pendingAgentDeepLinkPrompt = prompt
|
||||
self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link requires local confirmation")
|
||||
return
|
||||
@@ -2698,60 +2672,6 @@ 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,11 +20,10 @@ enum WatchMessagingError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
|
||||
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private let session: WCSession?
|
||||
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
|
||||
override init() {
|
||||
@@ -40,11 +39,11 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func isSupportedOnDevice() -> Bool {
|
||||
static func isSupportedOnDevice() -> Bool {
|
||||
WCSession.isSupported()
|
||||
}
|
||||
|
||||
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
||||
guard WCSession.isSupported() else {
|
||||
return WatchMessagingStatus(
|
||||
supported: false,
|
||||
@@ -71,7 +70,9 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandlerLock.lock()
|
||||
self.replyHandler = handler
|
||||
self.replyHandlerLock.unlock()
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
@@ -160,15 +161,19 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
self.replyHandler?(event)
|
||||
let handler: ((WatchQuickReplyEvent) -> Void)?
|
||||
self.replyHandlerLock.lock()
|
||||
handler = self.replyHandler
|
||||
self.replyHandlerLock.unlock()
|
||||
handler?(event)
|
||||
}
|
||||
|
||||
nonisolated private static func nonEmpty(_ value: String?) -> String? {
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
nonisolated private static func parseQuickReplyPayload(
|
||||
private static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
@@ -200,12 +205,13 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated { return }
|
||||
session.activate()
|
||||
await withCheckedContinuation { continuation in
|
||||
self.pendingActivationContinuations.append(continuation)
|
||||
for _ in 0..<8 {
|
||||
if session.activationState == .activated { return }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
private static func status(for session: WCSession) -> WatchMessagingStatus {
|
||||
WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
@@ -214,7 +220,7 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
activationState: activationStateLabel(session.activationState))
|
||||
}
|
||||
|
||||
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
||||
switch state {
|
||||
case .notActivated:
|
||||
"notActivated"
|
||||
@@ -229,42 +235,32 @@ final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServi
|
||||
}
|
||||
|
||||
extension WatchMessagingService: WCSessionDelegate {
|
||||
nonisolated func session(
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: (any Error)?)
|
||||
{
|
||||
if let error {
|
||||
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
||||
} 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()
|
||||
}
|
||||
return
|
||||
}
|
||||
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
||||
}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
@@ -274,19 +270,15 @@ extension WatchMessagingService: WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": true])
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.emitReply(event)
|
||||
}
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,6 @@ 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.
|
||||
@@ -1057,7 +1040,7 @@ final class TalkModeManager: NSObject {
|
||||
let request = makeRequest(outputFormat: outputFormat)
|
||||
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
|
||||
if self.interruptOnSpeech {
|
||||
do {
|
||||
@@ -1072,16 +1055,12 @@ 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")
|
||||
if Self.isPCMFormatRejectedByAPI(streamFailure.value) {
|
||||
self.pcmFormatUnavailable = true
|
||||
}
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
@@ -1091,7 +1070,7 @@ final class TalkModeManager: NSObject {
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: rawStream)
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
@@ -1513,12 +1492,9 @@ final class TalkModeManager: NSObject {
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||
}
|
||||
|
||||
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 resolvedKey =
|
||||
(self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ??
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||
await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey)
|
||||
@@ -1566,39 +1542,6 @@ 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 {
|
||||
@@ -1640,25 +1583,21 @@ final class TalkModeManager: NSObject {
|
||||
text: text,
|
||||
context: context,
|
||||
outputFormat: context.outputFormat)
|
||||
let rawStream: AsyncThrowingStream<Data, Error>
|
||||
let stream: AsyncThrowingStream<Data, Error>
|
||||
if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty {
|
||||
rawStream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks)
|
||||
} else {
|
||||
rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
stream = 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")
|
||||
if Self.isPCMFormatRejectedByAPI(streamFailure.value) {
|
||||
self.pcmFormatUnavailable = true
|
||||
}
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
@@ -1672,7 +1611,7 @@ final class TalkModeManager: NSObject {
|
||||
result = playback
|
||||
} else {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: rawStream)
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
@@ -1682,8 +1621,6 @@ 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
|
||||
@@ -1776,9 +1713,8 @@ private struct IncrementalSpeechBuffer {
|
||||
}
|
||||
|
||||
if !inCodeBlock {
|
||||
let currentChar = chars[idx]
|
||||
buffer.append(currentChar)
|
||||
if Self.isBoundary(currentChar) || Self.isSoftBoundary(currentChar, bufferedChars: buffer.count) {
|
||||
buffer.append(chars[idx])
|
||||
if Self.isBoundary(chars[idx]) {
|
||||
lastBoundary = idx + 1
|
||||
bufferAtBoundary = buffer
|
||||
inCodeBlockAtBoundary = inCodeBlock
|
||||
@@ -1805,10 +1741,6 @@ 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 {
|
||||
@@ -2183,10 +2115,6 @@ 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()
|
||||
|
||||
@@ -62,7 +62,3 @@ Sources/Voice/VoiceWakePreferences.swift
|
||||
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
|
||||
Sources/Voice/TalkModeManager.swift
|
||||
Sources/Voice/TalkOrbOverlay.swift
|
||||
Sources/LiveActivity/OpenClawActivityAttributes.swift
|
||||
Sources/LiveActivity/LiveActivityManager.swift
|
||||
ActivityWidget/OpenClawActivityWidgetBundle.swift
|
||||
ActivityWidget/OpenClawLiveActivity.swift
|
||||
|
||||
@@ -416,20 +416,6 @@ 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,4 +1,3 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -29,22 +28,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
478
apps/ios/audit-api-modernization.md
Normal file
478
apps/ios/audit-api-modernization.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# iOS API Modernization Audit Report
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** API Modernization Expert (Claude Opus 4.6)
|
||||
**Scope:** All Swift source files in `apps/ios/Sources/`, `apps/ios/WatchExtension/Sources/`, and `apps/ios/ShareExtension/`
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0
|
||||
**Swift Version:** 6.0 (strict concurrency: complete)
|
||||
**Xcode Version:** 16.0
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS codebase is well-maintained and has already adopted many modern Swift and iOS patterns. The Observation framework (`@Observable`, `@Bindable`, `@Environment(ModelType.self)`) is used consistently throughout. `NavigationStack` is used instead of the deprecated `NavigationView`. Swift 6 strict concurrency is enabled project-wide.
|
||||
|
||||
However, there are several areas where deprecated APIs remain in use, unnecessary availability checks exist (dead code given iOS 18.0 deployment target), and legacy callback-based APIs are wrapped in continuations where native async alternatives are available.
|
||||
|
||||
### Summary by Severity
|
||||
|
||||
| Severity | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Critical | 1 | Deprecated `NetService` usage (removed in future SDKs) |
|
||||
| High | 4 | Dead availability-check code, legacy callback wrapping |
|
||||
| Medium | 8 | Callback APIs with async alternatives, legacy patterns |
|
||||
| Low | 6 | Minor modernization opportunities, style improvements |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `NetService` Usage (Deprecated Since iOS 16)
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/GatewayServiceResolver.swift` (entire file)
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift` (lines ~560-657)
|
||||
|
||||
**Current Code:**
|
||||
`GatewayServiceResolver` is built entirely on `NetService` and `NetServiceDelegate`, which have been deprecated since iOS 16. `GatewayConnectionController` uses `NetService` for Bonjour resolution in `resolveBonjourServiceToHostPort`.
|
||||
|
||||
**Risk:** Apple may remove `NetService` entirely in a future SDK. The app already uses `NWBrowser` (Network framework) for discovery in `GatewayDiscoveryModel.swift`, creating an inconsistency where discovery uses the modern API but resolution falls back to the deprecated one.
|
||||
|
||||
**Recommended Replacement:** Migrate to `NWConnection` for TCP connection establishment and use the endpoint information from `NWBrowser` results directly, eliminating the need for a separate `NetService`-based resolver. The `NWBrowser.Result` already provides `NWEndpoint` values that can be used with `NWConnection` without resolution.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Unnecessary `#available(iOS 15.0, *)` Check
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, line 344
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 15.0, *) { ... }
|
||||
```
|
||||
|
||||
**Issue:** The deployment target is iOS 18.0, so this check is always true. The code inside the `#available` block executes unconditionally, and the compiler may warn about this.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and keep only the body.
|
||||
|
||||
### H-2: Dead `AVAssetExportSession` Fallback Code
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift`, lines ~222-249
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
|
||||
try await exportSession.export(to: fileURL, as: .mp4)
|
||||
} else {
|
||||
exportSession.outputURL = fileURL
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
// ...legacy error check...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `else` branch is dead code since the deployment target is iOS 18.0. The `#available` check is always true.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and the `else` branch entirely. Use only the modern `export(to:as:)` API.
|
||||
|
||||
### H-3: Callback-Based `UNUserNotificationCenter` APIs Wrapped in Continuations
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, lines ~429-462
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let settings = await withCheckedContinuation { cont in
|
||||
center.getNotificationSettings { settings in
|
||||
cont.resume(returning: settings)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` has had native async APIs since iOS 15:
|
||||
- `center.notificationSettings()` (replaces `getNotificationSettings`)
|
||||
- `center.notificationCategories()` (replaces `getNotificationCategories`)
|
||||
- `try await center.add(request)` (replaces `add(_:completionHandler:)`)
|
||||
|
||||
The Watch app (`WatchInboxStore.swift`, line 161) already correctly uses the modern async pattern: `await center.notificationSettings()`.
|
||||
|
||||
**Recommended Fix:** Replace all `withCheckedContinuation` wrappers around `UNUserNotificationCenter` with their native async equivalents.
|
||||
|
||||
### H-4: `NSItemProvider.loadItem` Callback Pattern in Share Extension
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, lines ~501-547
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
|
||||
// ...
|
||||
continuation.resume(returning: ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSItemProvider` has had modern async alternatives since iOS 16:
|
||||
- `try await provider.loadItem(forTypeIdentifier:)` for basic loading
|
||||
- `try await provider.loadDataRepresentation(for:)` with `UTType` parameter
|
||||
- `try await provider.loadFileRepresentation(for:)`
|
||||
|
||||
Three separate methods (`loadURLValue`, `loadTextValue`, `loadDataValue`) all wrap callbacks in continuations.
|
||||
|
||||
**Recommended Fix:** Adopt the modern `NSItemProvider` async APIs, using `UTType` parameters instead of string identifiers where possible.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `CLLocationManager` Delegate Pattern vs Modern `CLLocationUpdate` API
|
||||
|
||||
**File:** `apps/ios/Sources/Location/LocationService.swift` (entire file)
|
||||
|
||||
**Current Code:** Uses `CLLocationManagerDelegate` with:
|
||||
- `startUpdatingLocation()` / `stopUpdatingLocation()`
|
||||
- `startMonitoringSignificantLocationChanges()`
|
||||
- `requestWhenInUseAuthorization()` / `requestAlwaysAuthorization()`
|
||||
- `locationManager(_:didUpdateLocations:)` delegate callback
|
||||
|
||||
**Modern Alternative (iOS 17+):**
|
||||
- `CLLocationUpdate.liveUpdates()` async sequence for continuous location
|
||||
- `CLMonitor` for region monitoring and significant location changes
|
||||
- `CLLocationManager.requestWhenInUseAuthorization()` still required for authorization, but updates are consumed via async sequences
|
||||
|
||||
**Impact:** The delegate pattern works but requires more boilerplate and is harder to compose with async/await code.
|
||||
|
||||
**Recommended Fix:** Migrate `startLocationUpdates` to use `CLLocationUpdate.liveUpdates()` and consider `CLMonitor` for significant location changes. Keep the authorization request methods as-is (no async alternative for those).
|
||||
|
||||
### M-2: `CMMotionActivityManager` and `CMPedometer` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Motion/MotionService.swift`, lines 23-81
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
activityManager.queryActivityStarting(from: startDate, to: endDate, to: OperationQueue.main) { activities, error in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** CoreMotion APIs still use callbacks; there are no native async versions. However, wrapping in `withCheckedThrowingContinuation` is currently the correct approach.
|
||||
|
||||
**Recommended Fix:** No change needed at this time. Monitor for async CoreMotion APIs in future SDK releases.
|
||||
|
||||
### M-3: `EKEventStore.fetchReminders` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Reminders/RemindersService.swift`, lines 20-45
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { reminders in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** EventKit still uses callbacks for `fetchReminders`. The continuation wrapper is the correct approach for now.
|
||||
|
||||
**Recommended Fix:** No change needed. This is the standard pattern for callback-based EventKit APIs.
|
||||
|
||||
### M-4: `PHImageManager.requestImage` Synchronous Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`, line ~82
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
// ...
|
||||
imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: options) { image, _ in
|
||||
resultImage = image
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Uses `isSynchronous = true` which blocks the calling thread. Modern iOS apps should prefer async image loading. Consider using `PHImageManager`'s async image loading or the newer `PHPickerViewController` patterns for user-initiated selection.
|
||||
|
||||
**Recommended Fix:** If this code runs on a background thread (inside an actor), the synchronous pattern is acceptable for simplicity. Consider wrapping in a continuation if thread blocking becomes an issue.
|
||||
|
||||
### M-5: `NotificationCenter` Observer Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 105-113
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: UserDefaults.standard,
|
||||
queue: .main,
|
||||
using: { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handleUserDefaultsDidChange()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Modern Alternative (iOS 15+):**
|
||||
```swift
|
||||
// Use async notification sequence
|
||||
for await _ in NotificationCenter.default.notifications(named: UserDefaults.didChangeNotification) {
|
||||
self.handleUserDefaultsDidChange()
|
||||
}
|
||||
```
|
||||
|
||||
**Also in:** `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (uses `onReceive` with Combine publisher -- see M-8).
|
||||
|
||||
**Recommended Fix:** Replace callback-based observers with `NotificationCenter.default.notifications(named:)` async sequences in a `.task` modifier or dedicated Task.
|
||||
|
||||
### M-6: `DispatchQueue.asyncAfter` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/TCPProbe.swift`, line 39
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, line ~1016
|
||||
- `apps/ios/ShareExtension/ShareViewController.swift`, line 142
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
```
|
||||
|
||||
**Issue:** `DispatchQueue.asyncAfter` is a legacy GCD pattern. In Swift concurrency, `Task.sleep(nanoseconds:)` or `Task.sleep(for:)` is preferred. However, in `TCPProbe`, the GCD pattern is used within an `NWConnection` state handler context where a DispatchQueue is already in use, making it acceptable.
|
||||
|
||||
**Recommended Fix:**
|
||||
- `TCPProbe.swift`: Acceptable as-is (NWConnection requires a DispatchQueue).
|
||||
- `GatewayConnectionController.swift`: Replace with `Task.sleep` pattern.
|
||||
- `ShareViewController.swift`: Replace with `Task.sleep` + `MainActor.run`.
|
||||
|
||||
### M-7: `objc_sync_enter`/`objc_sync_exit` and `objc_setAssociatedObject`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, lines ~1039-1040, ~653
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
objc_sync_enter(connection)
|
||||
// ...
|
||||
objc_sync_exit(connection)
|
||||
```
|
||||
and
|
||||
```swift
|
||||
objc_setAssociatedObject(service, &resolvedKey, resolvedBox, .OBJC_ASSOCIATION_RETAIN)
|
||||
```
|
||||
|
||||
**Issue:** These are Objective-C runtime patterns. Swift has modern alternatives:
|
||||
- `OSAllocatedUnfairLock` (iOS 16+) or `Mutex` (proposed) for synchronization
|
||||
- Property wrappers or Swift-native patterns for associated state
|
||||
|
||||
Note: `TCPProbe.swift` correctly uses `OSAllocatedUnfairLock` already.
|
||||
|
||||
**Recommended Fix:** Replace `objc_sync_enter`/`objc_sync_exit` with `OSAllocatedUnfairLock`. For `objc_setAssociatedObject`, this will naturally be eliminated when migrating away from `NetService` (see C-1).
|
||||
|
||||
### M-8: Combine `Timer.publish` and `onReceive` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Onboarding/OnboardingWizardView.swift`, line ~72 (`Timer.publish`)
|
||||
- `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (`.onReceive(NotificationCenter.default.publisher(...))`)
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
@State private var autoAdvanceTimer = Timer.publish(every: 5.5, on: .main, in: .common).autoconnect()
|
||||
// ...
|
||||
.onReceive(self.autoAdvanceTimer) { _ in ... }
|
||||
```
|
||||
|
||||
**Issue:** `Timer.publish` is a Combine pattern. Modern SwiftUI alternatives include:
|
||||
- `.task { while !Task.isCancelled { ... try? await Task.sleep(...) } }` for recurring timers
|
||||
- `TimelineView(.periodic(from:, by:))` for UI-driven periodic updates
|
||||
|
||||
**Recommended Fix:** Replace `Timer.publish` with a `.task`-based loop using `Task.sleep`. Replace `onReceive(NotificationCenter.default.publisher(...))` with `.task` + `NotificationCenter.default.notifications(named:)` async sequence.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@unchecked Sendable` on `WatchConnectivityReceiver`
|
||||
|
||||
**File:** `apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift`, line 21
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { ... }
|
||||
```
|
||||
|
||||
**Issue:** `@unchecked Sendable` bypasses the compiler's sendability checks. The class holds a `WCSession?` and `WatchInboxStore` reference. Since `WatchInboxStore` is `@MainActor @Observable`, the receiver should ideally be restructured to use actor isolation or be marked `@MainActor`.
|
||||
|
||||
**Recommended Fix:** Consider making `WatchConnectivityReceiver` `@MainActor` or using an actor to protect shared state. The `WCSessionDelegate` methods dispatch to `@MainActor` already.
|
||||
|
||||
### L-2: `@unchecked Sendable` on `ScreenRecordService`
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift`
|
||||
|
||||
**Current Code:** Uses `@unchecked Sendable` with manual `NSLock`-based `CaptureState` synchronization.
|
||||
|
||||
**Issue:** Manual lock-based synchronization is error-prone. An actor would provide compiler-verified thread safety.
|
||||
|
||||
**Recommended Fix:** Consider converting `ScreenRecordService` to an actor, or at minimum replace `NSLock` with `OSAllocatedUnfairLock` for consistency with other parts of the codebase (e.g., `TCPProbe.swift`).
|
||||
|
||||
### L-3: `NSLock` Usage in `AudioBufferQueue`
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 15-38
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private final class AudioBufferQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSLock` is a valid synchronization primitive but `OSAllocatedUnfairLock` (iOS 16+) is more efficient and is already used elsewhere in the codebase.
|
||||
|
||||
**Recommended Fix:** Replace `NSLock` with `OSAllocatedUnfairLock` for consistency and performance. Note: this class is intentionally `@unchecked Sendable` because it runs on a realtime audio thread where actor isolation is not appropriate -- the manual lock pattern is correct here; just the lock type could be modernized.
|
||||
|
||||
### L-4: `DateFormatter` Usage Instead of `.formatted()`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift`, lines 49-67
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
```
|
||||
|
||||
**Issue:** Since iOS 15, Swift provides `Date.formatted()` with `FormatStyle` which is more type-safe and concise. The `WatchInboxView.swift` already uses the modern pattern: `updatedAt.formatted(date: .omitted, time: .shortened)`.
|
||||
|
||||
**Recommended Fix:** Replace `DateFormatter` with `Date.formatted(.dateTime.hour().minute().second())` for the time format and `Date.ISO8601FormatStyle` for ISO formatting.
|
||||
|
||||
### L-5: `UIScreen.main.bounds` Usage
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, line 31
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
|
||||
```
|
||||
|
||||
**Issue:** `UIScreen.main` is deprecated in iOS 16. In an extension context, `view.window?.windowScene?.screen` may not be available at `viewDidLoad` time, so the deprecation is harder to address here.
|
||||
|
||||
**Recommended Fix:** Since this is a share extension with limited lifecycle, this is acceptable. If refactoring, consider using trait collection or a fixed width, since the system manages extension sizing.
|
||||
|
||||
### L-6: String-Based `NSSortDescriptor` Key Path
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
NSSortDescriptor(key: "creationDate", ascending: false)
|
||||
```
|
||||
|
||||
**Issue:** String-based key paths are not type-safe. While Photos framework requires `NSSortDescriptor`, this is a known limitation of the framework.
|
||||
|
||||
**Recommended Fix:** No change needed. The Photos framework API requires `NSSortDescriptor` with string keys.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings (Already Modern)
|
||||
|
||||
The following modern patterns are already correctly adopted throughout the codebase:
|
||||
|
||||
| Pattern | Status | Files |
|
||||
|---------|--------|-------|
|
||||
| `@Observable` (Observation framework) | Adopted | `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `ScreenController`, `VoiceWakeManager`, `TalkModeManager`, `WatchInboxStore` |
|
||||
| `@Environment(ModelType.self)` | Adopted | All views consistently use this pattern |
|
||||
| `@Bindable` for two-way bindings | Adopted | `WatchInboxView`, various settings views |
|
||||
| `NavigationStack` (not `NavigationView`) | Adopted | All navigation uses `NavigationStack` |
|
||||
| Modern `onChange(of:) { _, newValue in }` | Adopted | All `onChange` modifiers use the two-parameter variant |
|
||||
| `NWBrowser` (Network framework) | Adopted | `GatewayDiscoveryModel` for Bonjour discovery |
|
||||
| `NWPathMonitor` (Network framework) | Adopted | `NetworkStatusService` |
|
||||
| `DataScannerViewController` (VisionKit) | Adopted | `QRScannerView` for QR code scanning |
|
||||
| `PhotosPicker` (PhotosUI) | Adopted | `OnboardingWizardView` |
|
||||
| `OSAllocatedUnfairLock` | Adopted | `TCPProbe` |
|
||||
| Swift 6 strict concurrency | Adopted | Project-wide `SWIFT_STRICT_CONCURRENCY: complete` |
|
||||
| `actor` isolation | Adopted | `CameraController` uses `actor` |
|
||||
| `@ObservationIgnored` | Adopted | `NodeAppModel` for non-tracked properties |
|
||||
| `OSLog` / `Logger` | Adopted | Throughout the codebase |
|
||||
| `async`/`await` | Adopted | Pervasive throughout the codebase |
|
||||
| No `ObservableObject` / `@StateObject` | Correct | No legacy `ObservableObject` usage found |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical (Immediate)
|
||||
1. **Migrate `NetService` to Network framework** (C-1) -- `GatewayServiceResolver` and `GatewayConnectionController` Bonjour resolution
|
||||
|
||||
### Phase 2: High (Next Sprint)
|
||||
2. **Remove dead `#available` checks** (H-1, H-2) -- `OpenClawApp.swift`, `CameraController.swift`
|
||||
3. **Replace `UNUserNotificationCenter` callback wrappers** (H-3) -- `OpenClawApp.swift`
|
||||
4. **Modernize `NSItemProvider` loading in Share Extension** (H-4) -- `ShareViewController.swift`
|
||||
|
||||
### Phase 3: Medium (Planned)
|
||||
5. **Migrate `CLLocationManager` delegate to `CLLocationUpdate`** (M-1) -- `LocationService.swift`
|
||||
6. **Replace `DispatchQueue.asyncAfter`** (M-6) -- `GatewayConnectionController.swift`, `ShareViewController.swift`
|
||||
7. **Replace `objc_sync` with `OSAllocatedUnfairLock`** (M-7) -- `GatewayConnectionController.swift`
|
||||
8. **Replace Combine `Timer.publish` and `onReceive`** (M-8) -- `OnboardingWizardView.swift`, `VoiceWakeWordsSettingsView.swift`
|
||||
9. **Replace callback-based `NotificationCenter` observers** (M-5) -- `VoiceWakeManager.swift`
|
||||
|
||||
### Phase 4: Low (Opportunistic)
|
||||
10. **Replace `NSLock` with `OSAllocatedUnfairLock`** (L-3) -- `VoiceWakeManager.swift`
|
||||
11. **Modernize `DateFormatter` to `FormatStyle`** (L-4) -- `GatewayDiscoveryDebugLogView.swift`
|
||||
12. **Address `@unchecked Sendable` patterns** (L-1, L-2) -- `WatchConnectivityReceiver`, `ScreenRecordService`
|
||||
|
||||
---
|
||||
|
||||
## Files Not Requiring Changes
|
||||
|
||||
The following files were audited and found to use modern patterns appropriately:
|
||||
|
||||
- `apps/ios/Sources/RootView.swift`
|
||||
- `apps/ios/Sources/RootTabs.swift`
|
||||
- `apps/ios/Sources/RootCanvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+Canvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift`
|
||||
- `apps/ios/Sources/Chat/ChatSheet.swift`
|
||||
- `apps/ios/Sources/Chat/IOSGatewayChatTransport.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceTab.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceWakePreferences.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySettingsStore.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayHealthMonitor.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectConfig.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionIssue.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySetupCode.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/KeychainStore.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenTab.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenWebView.swift`
|
||||
- `apps/ios/Sources/Onboarding/GatewayOnboardingView.swift`
|
||||
- `apps/ios/Sources/Onboarding/OnboardingStateStore.swift`
|
||||
- `apps/ios/Sources/Status/StatusPill.swift`
|
||||
- `apps/ios/Sources/Status/StatusGlassCard.swift`
|
||||
- `apps/ios/Sources/Status/StatusActivityBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayStatusBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayActionsDialog.swift`
|
||||
- `apps/ios/Sources/Status/VoiceWakeToast.swift`
|
||||
- `apps/ios/Sources/Device/DeviceInfoHelper.swift`
|
||||
- `apps/ios/Sources/Device/DeviceStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NetworkStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NodeDisplayName.swift`
|
||||
- `apps/ios/Sources/Services/NodeServiceProtocols.swift`
|
||||
- `apps/ios/Sources/Services/WatchMessagingService.swift`
|
||||
- `apps/ios/Sources/Services/NotificationService.swift`
|
||||
- `apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift`
|
||||
- `apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift`
|
||||
- `apps/ios/Sources/SessionKey.swift`
|
||||
- `apps/ios/Sources/Calendar/CalendarService.swift`
|
||||
- `apps/ios/Sources/Contacts/ContactsService.swift`
|
||||
- `apps/ios/Sources/EventKit/EventKitAuthorization.swift`
|
||||
- `apps/ios/Sources/Location/SignificantLocationMonitor.swift`
|
||||
- `apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxStore.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxView.swift`
|
||||
- `apps/ios/WatchApp/` (asset catalog only)
|
||||
324
apps/ios/audit-architecture.md
Normal file
324
apps/ios/audit-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# iOS App Architecture, Code Quality & Test Coverage Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, 16,244 LOC) and `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
OpenClawApp (@main)
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
NodeAppModel GatewayConnectionController
|
||||
(@Observable) (@Observable)
|
||||
[God Object] [Discovery + Connect]
|
||||
| |
|
||||
+-----------+-----------+ GatewayDiscoveryModel
|
||||
| | | | | GatewaySettingsStore
|
||||
| | | | | GatewayHealthMonitor
|
||||
| | | | |
|
||||
v v v v v
|
||||
Screen Voice Camera Services Gateway Sessions
|
||||
Ctrl Wake Ctrl (proto) (node + operator)
|
||||
Talk
|
||||
Mode
|
||||
|
||||
UI Layer (SwiftUI):
|
||||
RootCanvas -> ScreenWebView + StatusPill + Overlays
|
||||
RootTabs -> ScreenTab, VoiceTab, SettingsTab
|
||||
Onboarding -> OnboardingWizardView, QRScannerView
|
||||
Chat -> ChatSheet (wraps OpenClawChatUI package)
|
||||
|
||||
Service Layer (protocols in NodeServiceProtocols.swift):
|
||||
CameraServicing, ScreenRecordingServicing, LocationServicing,
|
||||
DeviceStatusServicing, PhotosServicing, ContactsServicing,
|
||||
CalendarServicing, RemindersServicing, MotionServicing,
|
||||
WatchMessagingServicing
|
||||
|
||||
Routing: NodeCapabilityRouter (command -> handler dictionary)
|
||||
|
||||
Shared Packages: OpenClawKit, OpenClawChatUI, OpenClawProtocol, SwabbleKit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Findings by Severity
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1. NodeAppModel is a God Object (2,787 LOC)
|
||||
- **File:** `Sources/Model/NodeAppModel.swift`
|
||||
- **Lines:** 1-2787 (entire file)
|
||||
- **Description:** NodeAppModel concentrates ~17 distinct responsibilities in a single 2,787-line class:
|
||||
1. Gateway WebSocket lifecycle (two sessions: node + operator)
|
||||
2. Gateway reconnect state machine with exponential backoff
|
||||
3. Background task management (grace periods, leases, suppression)
|
||||
4. Deep link handling and agent prompt routing
|
||||
5. Voice wake coordination (suspend/resume around other audio)
|
||||
6. Talk mode coordination
|
||||
7. Camera HUD state management
|
||||
8. Screen recording state
|
||||
9. Canvas/A2UI invoke handling (present, hide, navigate, evalJS, snapshot, push, reset)
|
||||
10. Camera invoke handling (list, snap, clip)
|
||||
11. Location invoke handling
|
||||
12. Device/Photos/Contacts/Calendar/Reminders/Motion invoke handling
|
||||
13. Watch messaging and notification mirroring
|
||||
14. Push notification (APNs) token management
|
||||
15. Share extension relay configuration
|
||||
16. Branding/config refresh from gateway
|
||||
17. Session key management and agent selection
|
||||
- **Impact:** Extremely difficult to test in isolation, reason about, or modify safely. The file already has `// swiftlint:disable type_body_length file_length` which indicates a known but unaddressed problem.
|
||||
- **Recommendation:** Extract at least these into separate types:
|
||||
- `GatewayConnectionLoop` (reconnect state machine, background lease management)
|
||||
- `NodeInvokeDispatcher` (all `handleXxxInvoke` methods, currently ~600 LOC)
|
||||
- `VoiceAudioCoordinator` (voice wake + talk mode suspend/resume logic)
|
||||
- `BackgroundLifecycleManager` (grace periods, suppression, leases)
|
||||
- `DeepLinkHandler` (agent prompt, deep link parsing/routing)
|
||||
- `PushNotificationManager` (APNs token, notification authorization)
|
||||
|
||||
#### C2. TalkModeManager is a God Object (2,153 LOC)
|
||||
- **File:** `Sources/Voice/TalkModeManager.swift`
|
||||
- **Lines:** 1-2153 (entire file, saved to disk due to size)
|
||||
- **Description:** TalkModeManager contains speech recognition, audio playback, gateway communication, provider API key management, push-to-talk state machine, and TTS. The file header acknowledges this: "This file intentionally centralizes talk mode state + behavior. It's large, and splitting would force `private` -> `fileprivate` across many members."
|
||||
- **Impact:** The `private` -> `fileprivate` concern is valid but solvable with extensions in the same file or a dedicated module with `internal` access.
|
||||
- **Recommendation:** Extract `TalkAudioPlayer`, `TalkSpeechRecognitionEngine`, `TalkConfigLoader`, `TalkPTTStateMachine` into separate files.
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1. GatewayConnectionController is oversized (1,058 LOC)
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift`
|
||||
- **Lines:** 1-1058
|
||||
- **Description:** Exceeds the 500 LOC guideline. Mixes discovery coordination, TLS fingerprint verification, Bonjour service resolution, loopback IP detection, URL building, capability/command/permission registration, and auto-connect logic.
|
||||
- **Recommendation:** Extract `GatewayTLSVerifier`, `LoopbackHostDetector` (static utility), and `GatewayCapabilityRegistrar` (caps/commands/permissions).
|
||||
|
||||
#### H2. SettingsTab is oversized (1,032 LOC)
|
||||
- **File:** `Sources/Settings/SettingsTab.swift`
|
||||
- **Lines:** 1-1032
|
||||
- **Description:** A single monolithic SwiftUI view with ~30 `@AppStorage` properties and multiple nested sections.
|
||||
- **Recommendation:** Extract section views: `GatewaySettingsSection`, `VoiceSettingsSection`, `DeviceSettingsSection`, `AdvancedSettingsSection`.
|
||||
|
||||
#### H3. OnboardingWizardView is oversized (884 LOC)
|
||||
- **File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
- **Lines:** 1-884
|
||||
- **Description:** Multi-step wizard with QR scanning, manual connection, photo picker, and pairing logic all in one view.
|
||||
- **Recommendation:** Extract per-step views: `OnboardingWelcomeStep`, `OnboardingConnectStep`, `OnboardingAuthStep`.
|
||||
|
||||
#### H4. Heavy UserDefaults coupling (no abstraction layer)
|
||||
- **Files:** `NodeAppModel.swift`, `GatewayConnectionController.swift`, `SettingsTab.swift`, `GatewaySettingsStore.swift`, `RootCanvas.swift`
|
||||
- **Description:** `UserDefaults.standard` is accessed directly throughout the codebase (~70+ direct reads/writes with raw string keys). There is no typed key registry or wrapper, so:
|
||||
- Key typos compile silently
|
||||
- Default values are duplicated (e.g., `"camera.enabled"` checked with fallback `true` in two places)
|
||||
- Testing requires the `withUserDefaults` helper which mutates the shared `UserDefaults.standard`
|
||||
- **Recommendation:** Create a `Settings` enum with typed keys (similar to `VoiceWakePreferences`) and use dependency injection for `UserDefaults`.
|
||||
|
||||
#### H5. Significant test coverage gaps for critical paths
|
||||
- **Description:** Several critical modules have zero test coverage. See the Test Coverage Gap Analysis table below.
|
||||
- **Impact:** Changes to gateway connection lifecycle, background task management, voice/talk coordination, and canvas interaction cannot be regression-tested.
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1. Inconsistent module boundary patterns
|
||||
- **Description:** Some modules use proper protocol-based DI (camera, screen recording, location, device status, photos, contacts, calendar, reminders, motion, watch messaging via `NodeServiceProtocols.swift`), while others use concrete types directly:
|
||||
- `VoiceWakeManager` and `TalkModeManager` are concrete, not protocol-backed
|
||||
- `GatewayHealthMonitor` is concrete (but has testable init with sleep injection)
|
||||
- `ScreenController` is concrete with no protocol
|
||||
- `NotificationCentering` protocol exists but is ad hoc (not in `NodeServiceProtocols.swift`)
|
||||
- **Recommendation:** Add protocols for `VoiceWakeServicing`, `TalkModeServicing`, `ScreenControlling` to enable test doubles.
|
||||
|
||||
#### M2. Closure-based wiring instead of protocol conformance
|
||||
- **Files:** `NodeAppModel.swift:178-216`, `ScreenController.swift:14-18`
|
||||
- **Description:** `ScreenController.onDeepLink` and `ScreenController.onA2UIAction` are closure properties rather than delegate protocols. Similarly, `VoiceWakeManager.configure(onCommand:)` uses a closure. This makes the dependency graph harder to trace.
|
||||
- **Recommendation:** Consider delegate protocols for clearer contracts, or at minimum document the callback contracts.
|
||||
|
||||
#### M3. OpenClawApp.swift mixes concerns (541 LOC)
|
||||
- **File:** `Sources/OpenClawApp.swift`
|
||||
- **Lines:** 1-541
|
||||
- **Description:** Contains three distinct concerns in one file:
|
||||
1. `OpenClawAppDelegate` (push notifications, background tasks)
|
||||
2. `WatchPromptNotificationBridge` (notification category management, 200+ LOC)
|
||||
3. `OpenClawApp` (SwiftUI app entry point)
|
||||
- **Recommendation:** Extract `WatchPromptNotificationBridge` to its own file.
|
||||
|
||||
#### M4. GatewayDiagnostics embedded in GatewaySettingsStore file
|
||||
- **File:** `Sources/Gateway/GatewaySettingsStore.swift:352-448`
|
||||
- **Description:** `GatewayDiagnostics` enum (file-based logging) is defined at the bottom of `GatewaySettingsStore.swift` with no relation to settings storage.
|
||||
- **Recommendation:** Move to its own file `Gateway/GatewayDiagnostics.swift`.
|
||||
|
||||
#### M5. Duplicate code patterns in invoke handlers
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:1213-1358`
|
||||
- **Description:** Every `handleXxxInvoke` method follows the same pattern: decode params -> call service -> encode payload -> return response. The 12 invoke handlers repeat this boilerplate with minor variations. The `default:` case error response is duplicated 9 times verbatim.
|
||||
- **Recommendation:** Create a generic `invokeServiceMethod<P: Decodable, R: Encodable>` helper that handles the decode-call-encode-response cycle.
|
||||
|
||||
#### M6. No formal error domain or error catalog
|
||||
- **Description:** Errors are constructed ad hoc using `NSError(domain:code:userInfo:)` with inconsistent domains ("Screen", "Gateway", "Camera", "NodeAppModel", "GatewayHealthMonitor", "VoiceWake") and magic number codes. Only `CameraController.CameraError` uses a proper Swift error enum.
|
||||
- **Recommendation:** Define a unified `OpenClawIOSError` enum with cases for each domain, or at minimum use consistent error domains and documented code ranges.
|
||||
|
||||
#### M7. Two gateway sessions managed in parallel without shared state machine
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:96-98`
|
||||
- **Description:** `nodeGateway` and `operatorGateway` are two independent `GatewayNodeSession` instances with separate reconnect loops. Their connected states (`gatewayConnected`, `operatorConnected`) are tracked independently, but the UI only shows one "gateway status". Disconnect/reconnect of one does not coordinate with the other.
|
||||
- **Recommendation:** Extract a `DualGatewaySessionManager` that manages both sessions' lifecycles as a coordinated unit.
|
||||
|
||||
---
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1. `RootView.swift` is a trivial wrapper (7 LOC)
|
||||
- **File:** `Sources/RootView.swift`
|
||||
- **Description:** Contains only `struct RootView: View { var body: some View { RootCanvas() } }`. This adds an unnecessary layer of indirection.
|
||||
- **Recommendation:** Remove and use `RootCanvas` directly, or document why the indirection exists.
|
||||
|
||||
#### L2. Access control could be tighter
|
||||
- **Description:** Many types use default `internal` access where `private` or `fileprivate` would be more appropriate. For example:
|
||||
- `NodeAppModel.gatewayStatusText`, `nodeStatusText`, `operatorStatusText` are `var` (settable) from outside
|
||||
- `GatewayDiscoveryModel.gateways` is `var` (not `private(set)`)
|
||||
- `VoiceWakeManager.isEnabled`, `isListening` are publicly settable
|
||||
- **Recommendation:** Prefer `private(set)` for observable properties that should only be modified internally.
|
||||
|
||||
#### L3. `#if DEBUG` test hooks pattern
|
||||
- **Files:** `GatewayConnectionController.swift:929-989`, `VoiceWakeManager.swift:477-483`, `NodeAppModel.swift` (via `_test_` prefixed methods)
|
||||
- **Description:** Test hooks are exposed via `#if DEBUG` extensions with `_test_` prefixes. While functional, this pollutes the type's API surface.
|
||||
- **Recommendation:** This is a reasonable pattern for host-app tests. Consider using `@_spi(Testing)` when available in Swift 6 for cleaner separation.
|
||||
|
||||
#### L4. Naming inconsistency: `ThrowingContinuationSupport`
|
||||
- **File:** `Sources/OpenClawApp.swift:459`
|
||||
- **Description:** References `ThrowingContinuationSupport.resumeVoid` which appears to be defined in OpenClawKit. The name is verbose; a simple extension on `CheckedContinuation` would be more idiomatic.
|
||||
|
||||
#### L5. `GatewayTLSFingerprintProbe` uses `objc_sync_enter/exit` instead of a lock
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
- **Description:** `objc_sync_enter(self)` / `objc_sync_exit(self)` is an Objective-C runtime synchronization primitive. Modern Swift code should use `NSLock`, `os_unfair_lock`, or `Mutex` (Swift 6).
|
||||
- **Recommendation:** Replace with `NSLock` or `Mutex` for consistency with other lock usage (e.g., `NotificationInvokeLatch` uses `NSLock`).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis
|
||||
|
||||
| Source File | LOC | Test File | Test LOC | Coverage |
|
||||
|---|---|---|---|---|
|
||||
| `Model/NodeAppModel.swift` | 2787 | `NodeAppModelInvokeTests.swift` | 478 | **Partial** - invoke dispatch only; no tests for reconnect, background, deep links |
|
||||
| `Voice/TalkModeManager.swift` | 2153 | `TalkModeConfigParsingTests.swift` | 31 | **Minimal** - config parsing only; no PTT, speech, or playback tests |
|
||||
| `Gateway/GatewayConnectionController.swift` | 1058 | `GatewayConnectionControllerTests.swift` + `GatewayConnectionSecurityTests.swift` | 226 | **Partial** - security + basic flow; no TLS probe, Bonjour resolve, or autoconnect tests |
|
||||
| `Settings/SettingsTab.swift` | 1032 | `SwiftUIRenderSmokeTests.swift` (1 test) | ~8 | **Smoke only** - verifies view hierarchy builds |
|
||||
| `Onboarding/OnboardingWizardView.swift` | 884 | None | 0 | **None** |
|
||||
| `OpenClawApp.swift` | 541 | None | 0 | **None** - WatchPromptNotificationBridge untested |
|
||||
| `Voice/VoiceWakeManager.swift` | 483 | `VoiceWakeManagerStateTests.swift` + `VoiceWakeManagerExtractCommandTests.swift` | 144 | **Good** - state transitions + command extraction |
|
||||
| `Gateway/GatewaySettingsStore.swift` | 448 | `GatewaySettingsStoreTests.swift` | 197 | **Good** |
|
||||
| `RootCanvas.swift` | 429 | `SwiftUIRenderSmokeTests.swift` | ~8 | **Smoke only** |
|
||||
| `Onboarding/GatewayOnboardingView.swift` | 371 | None | 0 | **None** |
|
||||
| `Screen/ScreenRecordService.swift` | 350 | `ScreenRecordServiceTests.swift` | 32 | **Minimal** |
|
||||
| `Camera/CameraController.swift` | 339 | `CameraControllerClampTests.swift` + `CameraControllerErrorTests.swift` | 38 | **Minimal** - clamp/error only; no capture flow tests |
|
||||
| `Services/WatchMessagingService.swift` | 284 | None (mock in NodeAppModelInvokeTests) | 0 | **None** |
|
||||
| `Screen/ScreenController.swift` | 267 | `ScreenControllerTests.swift` | 87 | **Good** |
|
||||
| `Contacts/ContactsService.swift` | 210 | None | 0 | **None** |
|
||||
| `Screen/ScreenWebView.swift` | 193 | None | 0 | **None** |
|
||||
| `Gateway/GatewayDiscoveryModel.swift` | 181 | `GatewayDiscoveryModelTests.swift` | 22 | **Minimal** |
|
||||
| `Location/LocationService.swift` | 177 | None | 0 | **None** |
|
||||
| `Media/PhotoLibraryService.swift` | 164 | None | 0 | **None** |
|
||||
| `Chat/IOSGatewayChatTransport.swift` | 142 | `IOSGatewayChatTransportTests.swift` | 30 | **Minimal** |
|
||||
| `Calendar/CalendarService.swift` | 135 | None | 0 | **None** |
|
||||
| `Reminders/RemindersService.swift` | 133 | None | 0 | **None** |
|
||||
| `Motion/MotionService.swift` | 100 | None | 0 | **None** |
|
||||
| `Model/NodeAppModel+WatchNotifyNormalization.swift` | 103 | `VoiceWakeGatewaySyncTests.swift` (partial) | 22 | **Minimal** |
|
||||
| `Model/NodeAppModel+Canvas.swift` | 59 | None | 0 | **None** |
|
||||
| `Gateway/GatewayHealthMonitor.swift` | 85 | None | 0 | **None** |
|
||||
| `Gateway/KeychainStore.swift` | 48 | `KeychainStoreTests.swift` | 22 | **Minimal** |
|
||||
| `Onboarding/OnboardingStateStore.swift` | 52 | `OnboardingStateStoreTests.swift` | 57 | **Good** |
|
||||
| `Gateway/GatewayConnectionIssue.swift` | 71 | `GatewayConnectionIssueTests.swift` | 33 | **Good** |
|
||||
| `SessionKey.swift` | 23 | Tested via `NodeAppModelInvokeTests` | - | **Good** (indirectly) |
|
||||
| `Settings/SettingsNetworkingHelpers.swift` | 40 | `SettingsNetworkingHelpersTests.swift` | 50 | **Good** |
|
||||
| `Voice/VoiceWakePreferences.swift` | 44 | `VoiceWakePreferencesTests.swift` | 38 | **Good** |
|
||||
| `Device/NodeDisplayName.swift` | 48 | Tested via GatewayConnectionControllerTests | - | **Partial** |
|
||||
|
||||
### Coverage Summary
|
||||
- **63 source files**, **25 test files** (24 test + 1 helper)
|
||||
- **17 source modules with zero test coverage** (service implementations, onboarding views, several gateway files)
|
||||
- **Test LOC ratio:** 1,884 / 16,244 = **11.6%** (low for a production app)
|
||||
- **Test framework:** Swift Testing (`@Test`, `#expect`) -- modern and correct
|
||||
- **Test patterns:** Good use of mocks (MockWatchMessagingService), `withUserDefaults` helper for isolation, `_test_` hooks for internal access. SwiftUI render smoke tests validate view hierarchy construction.
|
||||
|
||||
### Critical Untested Paths
|
||||
1. **Gateway reconnect state machine** - the most complex logic in the app (background lease, pairing pause, backoff) has zero tests
|
||||
2. **Background lifecycle management** - grace periods, suppression, wake handling untested
|
||||
3. **Onboarding flow** - 1,255 LOC across 3 files with zero tests
|
||||
4. **Push notification handling** - APNs registration, silent push, background refresh untested
|
||||
5. **TalkModeManager** - 2,153 LOC with only 31 LOC of config parsing tests
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Assessment
|
||||
|
||||
### Well-Injected (protocol-based, testable)
|
||||
All services in `NodeServiceProtocols.swift` are protocol-based with default production implementations:
|
||||
- `CameraServicing` -> `CameraController`
|
||||
- `ScreenRecordingServicing` -> `ScreenRecordService`
|
||||
- `LocationServicing` -> `LocationService`
|
||||
- `DeviceStatusServicing` -> `DeviceStatusService`
|
||||
- `PhotosServicing` -> `PhotoLibraryService`
|
||||
- `ContactsServicing` -> `ContactsService`
|
||||
- `CalendarServicing` -> `CalendarService`
|
||||
- `RemindersServicing` -> `RemindersService`
|
||||
- `MotionServicing` -> `MotionService`
|
||||
- `WatchMessagingServicing` -> `WatchMessagingService`
|
||||
- `NotificationCentering` -> `LiveNotificationCenter`
|
||||
|
||||
`NodeAppModel.init()` accepts all of these via parameters with defaults -- excellent DI pattern.
|
||||
|
||||
### Not Injected (hardcoded dependencies)
|
||||
- `GatewaySettingsStore` - static enum, not injectable. Tests must use real `UserDefaults`/Keychain.
|
||||
- `GatewayDiagnostics` - static enum with file I/O, not injectable.
|
||||
- `GatewayDiscoveryModel` - concrete class created inside `GatewayConnectionController.init`.
|
||||
- `GatewayHealthMonitor` - created internally by `NodeAppModel` (but has testable init).
|
||||
- `VoiceWakeManager` - created internally, injected into SwiftUI environment.
|
||||
- `TalkModeManager` - injected via `NodeAppModel.init` parameter (good).
|
||||
- `ScreenController` - injected via `NodeAppModel.init` parameter (good).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### Observation Framework Usage
|
||||
The app uses Swift's `Observation` framework (`@Observable`) consistently:
|
||||
- `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `VoiceWakeManager`, `TalkModeManager`, `ScreenController` are all `@Observable`.
|
||||
- SwiftUI views access them via `@Environment(Type.self)`.
|
||||
- No legacy `ObservableObject` / `@StateObject` patterns found -- this is correct per CLAUDE.md guidance.
|
||||
|
||||
### Environment Propagation
|
||||
```
|
||||
OpenClawApp
|
||||
|-- @State NodeAppModel -> .environment(appModel)
|
||||
|-- @State GatewayConnectionController -> .environment(gatewayController)
|
||||
|-- appModel.voiceWake (VoiceWakeManager) -> .environment(appModel.voiceWake)
|
||||
```
|
||||
This is clean, though `voiceWake` being both a property of `NodeAppModel` AND injected separately into the environment creates a potential consistency issue if they ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Strengths
|
||||
|
||||
1. **Strong protocol-based DI for services** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities, enabling easy mocking in tests.
|
||||
2. **Modern Swift 6 / Observation adoption** - No legacy `ObservableObject` patterns; strict concurrency enabled.
|
||||
3. **NodeCapabilityRouter** - Clean command-routing pattern that decouples command registration from handling.
|
||||
4. **Dual gateway session architecture** - Separating node (device capabilities) from operator (chat/config) connections is architecturally sound.
|
||||
5. **GatewayConnectConfig** - Single source of truth struct for connection parameters.
|
||||
6. **Consistent input validation** - Nearly every string input is trimmed and empty-checked.
|
||||
7. **Keychain-based credential storage** - Sensitive data (tokens, passwords) stored in Keychain, not UserDefaults.
|
||||
8. **`CameraController` uses actor isolation** - Correct concurrency pattern for hardware resource.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Refactoring Priority
|
||||
|
||||
1. **[CRITICAL]** Split `NodeAppModel` into 5-6 focused types (highest ROI for testability)
|
||||
2. **[CRITICAL]** Split `TalkModeManager` into 3-4 focused types
|
||||
3. **[HIGH]** Add tests for gateway reconnect state machine
|
||||
4. **[HIGH]** Add tests for background lifecycle management
|
||||
5. **[HIGH]** Extract `SettingsTab` into section views
|
||||
6. **[MEDIUM]** Create typed `UserDefaults` key registry
|
||||
7. **[MEDIUM]** Unify error handling with a proper error catalog
|
||||
8. **[MEDIUM]** Extract duplicate invoke handler boilerplate
|
||||
399
apps/ios/audit-concurrency.md
Normal file
399
apps/ios/audit-concurrency.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Swift 6 Concurrency Audit: OpenClaw iOS App
|
||||
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~15K LOC)
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** Concurrency Auditor Agent
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Files audited | 63 |
|
||||
| `@MainActor` classes | 8 |
|
||||
| `actor` types | 1 (`CameraController`) |
|
||||
| `@unchecked Sendable` types | 9 |
|
||||
| `@preconcurrency` imports | 2 (`UserNotifications`, `WatchConnectivity`) |
|
||||
| `@preconcurrency` conformances | 2 (`UNUserNotificationCenterDelegate`, `NetServiceDelegate`) |
|
||||
| `nonisolated(unsafe)` usages | 1 |
|
||||
| `NSLock` usages | 6 |
|
||||
| `DispatchQueue` usages | 7 |
|
||||
| `objc_sync_enter/exit` usages | 1 |
|
||||
| `CheckedContinuation` usages | ~25 |
|
||||
| `@Observable` (Observation framework) types | 6 |
|
||||
| `ObservableObject` types | 0 |
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
The codebase is in **good shape for Swift 6 strict concurrency**. The major model types use `@MainActor` + `@Observable` (Observation framework), there are zero `ObservableObject` usages, and the actor model is applied consistently. There are no `@Sendable` annotations missing on closure parameters in any obvious way, and the use of `@unchecked Sendable` is confined to genuine low-level synchronization wrappers. However, there are several areas that warrant attention.
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `GatewayTLSFingerprintProbe` uses `objc_sync_enter` + `@unchecked Sendable` with unsynchronized `didFinish` read
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:992-1058`
|
||||
**Severity:** Critical (potential data race)
|
||||
|
||||
```swift
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private var didFinish = false // line 996
|
||||
private var session: URLSession? // line 997
|
||||
private var task: URLSessionWebSocketTask? // line 998
|
||||
...
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self) // line 1039
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `start()` method (line 1006) reads and writes `self.session` and `self.task` without any lock. The `DispatchQueue.global().asyncAfter` timeout on line 1016 calls `finish()` from a background queue while `start()` sets properties on the caller's thread. Additionally, `URLSession` delegate callbacks arrive on an arbitrary delegate queue (nil was passed for `delegateQueue`), which means `urlSession(_:didReceive:completionHandler:)` and `finish()` can race.
|
||||
|
||||
**Recommendation:** Replace `objc_sync_enter/exit` with `NSLock` or `OSAllocatedUnfairLock`. Ensure all mutable state (`didFinish`, `session`, `task`) is accessed under the lock. Better yet, convert to an `actor` since this is a short-lived async operation. Alternatively, use `OSAllocatedUnfairLock<State>` wrapping a struct.
|
||||
|
||||
---
|
||||
|
||||
### C-2: `PhotoCaptureDelegate` and `MovieFileDelegate` lack synchronization on `didResume`
|
||||
|
||||
**File:** `Camera/CameraController.swift:260-339`
|
||||
**Severity:** Critical (potential double continuation resume)
|
||||
|
||||
```swift
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false // NOT thread-safe
|
||||
|
||||
func photoOutput(...) {
|
||||
guard !self.didResume else { return } // line 273
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
func photoOutput(...didFinishCaptureFor...) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return } // line 303
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `AVCapturePhotoCaptureDelegate` callbacks can arrive on different queues. The `didResume` flag is a plain `Bool` with no synchronization. If `didFinishProcessingPhoto` and `didFinishCaptureFor` are called concurrently (possible under certain error conditions), both could read `didResume` as `false` and resume the continuation twice, which is a fatal crash in debug builds and undefined behavior in release.
|
||||
|
||||
**Recommendation:** Protect `didResume` with `OSAllocatedUnfairLock<Bool>` or `NSLock`. The same issue applies to `MovieFileDelegate` on line 309.
|
||||
|
||||
---
|
||||
|
||||
### C-3: `GatewayDiagnostics.logWritesSinceCheck` is `nonisolated(unsafe)` static var
|
||||
|
||||
**File:** `Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** Critical (data race)
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
**Issue:** This counter is read and written inside `queue.async {}` blocks on `GatewayDiagnostics.queue`, but `nonisolated(unsafe)` tells the compiler to skip checking. The access is actually serialized by the private `DispatchQueue`, so it is functionally safe -- however, `nonisolated(unsafe)` is a red flag for Swift 6 audits because it permanently suppresses the compiler's data-race safety checks.
|
||||
|
||||
**Recommendation:** Replace with proper synchronization visible to the compiler. Either:
|
||||
1. Make it a local variable inside the `DispatchQueue` closure scope, or
|
||||
2. Wrap in `OSAllocatedUnfairLock<Int>` or a dedicated `actor`, or
|
||||
3. Since all accesses are on `GatewayDiagnostics.queue`, convert to a `@Sendable`-safe pattern that doesn't require `nonisolated(unsafe)`.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: `ScreenRecordService` is `@unchecked Sendable` but holds no state -- its inner `CaptureState` synchronizes via NSLock but `UncheckedSendableBox` silences Sendable checks
|
||||
|
||||
**File:** `Screen/ScreenRecordService.swift:4-11`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UncheckedSendableBox` wraps **any** `T` (including non-Sendable types like `CMSampleBuffer`) and marks it `@unchecked Sendable`. This is used to pass `CMSampleBuffer` across threads in the capture handler. While `CMSampleBuffer` is effectively thread-safe for read-only access, this pattern silences the compiler completely and could mask future issues if the box is used for other types.
|
||||
|
||||
**Recommendation:** Use `nonisolated(unsafe) let value: T` instead if on Swift 6.2+, or document the specific thread-safety invariant. Consider constraining `T: Sendable` on the generic and handling `CMSampleBuffer` separately with a targeted unsafe annotation.
|
||||
|
||||
### H-2: `WatchMessagingService` is `@unchecked Sendable` with mutable `replyHandler` protected only by NSLock
|
||||
|
||||
**File:** `Services/WatchMessagingService.swift:23-28`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
```
|
||||
|
||||
**Issue:** While the `replyHandler` is properly protected by `NSLock`, the `session` property (`WCSession?`) is accessed from both the main thread (via delegate callbacks forwarded with `@preconcurrency`) and potentially from WatchConnectivity's internal threads. The `WCSession` properties like `isPaired`, `isWatchAppInstalled`, `isReachable` are read in `status(for:)` without synchronization and could race with delegate callbacks.
|
||||
|
||||
**Recommendation:** Convert to an `actor` or ensure all `WCSession` property reads happen on a specific isolation context. The lock properly protects `replyHandler`, so this is a moderate risk.
|
||||
|
||||
### H-3: `LocationService` stores `CheckedContinuation` as instance vars without synchronization between `nonisolated` delegate callbacks and `@MainActor` methods
|
||||
|
||||
**File:** `Location/LocationService.swift:13-14, 136-176`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
```
|
||||
|
||||
The delegate methods are marked `nonisolated`:
|
||||
```swift
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `nonisolated` delegate methods create `Task { @MainActor in }` to hop back to the main actor before accessing continuations. This is the correct pattern. However, there is a subtle race: if two delegate callbacks arrive in rapid succession, both could queue `@MainActor` tasks, and the second one would find the continuation already `nil`. This is handled (the `if let` guards), but the pattern is fragile. More importantly, `CLLocationManager` requires its delegate methods to be called on the queue it was created on. Since the class is `@MainActor`, the manager is created on main, and iOS should deliver delegate callbacks on main -- making the `nonisolated` annotation somewhat misleading.
|
||||
|
||||
**Recommendation:** Since `CLLocationManager` delivers callbacks on the thread/queue of the delegate's assigned queue (main in this case), the `nonisolated` annotation is technically unnecessary and may confuse future maintainers. Consider removing `nonisolated` and letting `@MainActor` inheritance apply. This would also let the compiler verify the continuation access is safe.
|
||||
|
||||
### H-4: `LiveNotificationCenter` is `@unchecked Sendable` wrapping a non-Sendable `UNUserNotificationCenter`
|
||||
|
||||
**File:** `Services/NotificationService.swift:18-58`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` is not `Sendable`. Wrapping it in `@unchecked Sendable` silences the compiler. In practice, `UNUserNotificationCenter.current()` returns a singleton that is thread-safe, so this is functionally fine -- but the compiler cannot verify this.
|
||||
|
||||
**Recommendation:** This is acceptable given `UNUserNotificationCenter.current()` is a thread-safe singleton. Document the invariant with a comment explaining why `@unchecked Sendable` is safe here. Alternatively, access the center via `UNUserNotificationCenter.current()` each time instead of storing it.
|
||||
|
||||
### H-5: `NetworkStatusService` is `@unchecked Sendable` but has no mutable state
|
||||
|
||||
**File:** `Device/NetworkStatusService.swift:5`
|
||||
**Severity:** High (misleading annotation)
|
||||
|
||||
```swift
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
```
|
||||
|
||||
**Issue:** `NetworkStatusService` has no stored properties at all. It creates `NWPathMonitor` locally in each method call. The `@unchecked Sendable` is unnecessary because a stateless final class is inherently `Sendable`.
|
||||
|
||||
**Recommendation:** Remove `@unchecked` -- just conform to `Sendable` directly. The class has no mutable state and is `final`, so it qualifies for automatic Sendable conformance.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `TalkModeManager` `pttCompletion` continuation stored as instance var could leak
|
||||
|
||||
**File:** `Voice/TalkModeManager.swift:43`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private var pttCompletion: CheckedContinuation<OpenClawTalkPTTStopPayload, Never>?
|
||||
```
|
||||
|
||||
**Issue:** If `pttCompletion` is set but the manager is deinitialized or the PTT session is interrupted without resuming it, the continuation will leak. `CheckedContinuation` logs a warning in debug builds when it is never resumed, and in production the caller will hang indefinitely.
|
||||
|
||||
**Recommendation:** Add a `deinit` or cleanup path that resumes `pttCompletion` with a default/error value. Also verify that all code paths that set `pttCompletion` eventually resume it (including error paths, cancellation, and mode changes).
|
||||
|
||||
### M-2: Heavy use of `Task { @MainActor in }` hops in code that is already `@MainActor`
|
||||
|
||||
**Files:** Multiple (OpenClawApp.swift:30-47, NodeAppModel.swift:179-207, etc.)
|
||||
**Severity:** Medium (performance/clarity)
|
||||
|
||||
```swift
|
||||
// In OpenClawAppDelegate which is already @MainActor:
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** When code is already on `@MainActor`, creating `Task { @MainActor in }` is redundant in terms of isolation but does defer execution to the next event loop tick. If the intent is immediate execution, this is a performance anti-pattern. If the intent is deferral, it should be documented.
|
||||
|
||||
**Recommendation:** Where immediate execution is intended, call the method directly. Where deferral is intentional, add a comment explaining why. In Swift 6.2 with `nonisolated(nonsending)` defaults, these patterns will behave differently.
|
||||
|
||||
### M-3: `GatewayDiscoveryModel` browser callbacks use closures that capture `self` without explicit `@Sendable`
|
||||
|
||||
**File:** `Gateway/GatewayDiscoveryModel.swift:60-96`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
...
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state // MainActor state access
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { ... }
|
||||
```
|
||||
|
||||
**Issue:** These closures capture `self` (a `@MainActor` `@Observable` class) and mutate its state. If `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches these callbacks on a background queue (which NWBrowser does by default), this would be a main-actor isolation violation. The callbacks access `@MainActor`-isolated properties without explicitly hopping to the main actor.
|
||||
|
||||
**Recommendation:** Verify that `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches callbacks on the main queue. If not, wrap the callback bodies in `Task { @MainActor in ... }` or `await MainActor.run { ... }`. This is a potential data race if callbacks arrive off-main.
|
||||
|
||||
### M-4: `withObservationTracking` + `onChange` pattern in `GatewayConnectionController.observeDiscovery()` could miss updates
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery() // re-register
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `onChange` handler in `withObservationTracking` fires at most once per registration. The recursive re-registration inside `Task { @MainActor in }` means there is a window between when the `onChange` fires and when the new tracking is registered where changes could be missed. In practice, the `Task` hop is fast, but under heavy load or if the main actor queue is busy, rapid changes to `discovery.gateways` could be dropped.
|
||||
|
||||
**Recommendation:** This is a known limitation of `withObservationTracking` outside SwiftUI. Consider using `AsyncStream` or `Combine` publisher from the discovery model instead, which provides continuous observation without re-registration gaps.
|
||||
|
||||
### M-5: `GatewayServiceResolver` does not protect `didFinish` flag with a lock
|
||||
|
||||
**File:** `Gateway/GatewayServiceResolver.swift:9, 41-47`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private var didFinish = false
|
||||
|
||||
private func finish(result: ...) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NetServiceDelegate` callbacks can theoretically arrive on multiple threads (depending on how the service is scheduled). The `didFinish` flag is not synchronized. If `netServiceDidResolveAddress` and `netService(_:didNotResolve:)` are called concurrently, `finish` could be called twice.
|
||||
|
||||
**Recommendation:** Add `NSLock` protection or use `OSAllocatedUnfairLock<Bool>` for `didFinish`. Alternatively, ensure the service is always scheduled on the main run loop (which `BonjourServiceResolverSupport.start` may already do).
|
||||
|
||||
### M-6: `ContactsService`, `CalendarService`, `RemindersService`, `MotionService`, `PhotoLibraryService` conform to `Sendable` protocols but are plain classes without actor isolation
|
||||
|
||||
**Files:** Various service files
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class ContactsService: ContactsServicing { ... }
|
||||
// ContactsServicing: Sendable
|
||||
```
|
||||
|
||||
**Issue:** These classes have no mutable stored properties and are `final`, which technically makes them safe to mark `Sendable`. However, they don't explicitly declare `Sendable` conformance -- they inherit it through their protocol conformances (`ContactsServicing: Sendable`). The Swift 6 compiler will flag this because a `final class` without explicit `Sendable` or `@unchecked Sendable` conformance cannot implicitly satisfy `Sendable` requirements from protocols unless it is provably safe (no mutable state).
|
||||
|
||||
**Recommendation:** Since these classes are stateless and `final`, add explicit `: Sendable` conformance or verify they compile cleanly under strict concurrency.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@preconcurrency import UserNotifications` and `@preconcurrency import WatchConnectivity` suppress Sendable warnings
|
||||
|
||||
**Files:** `OpenClawApp.swift:7`, `Services/WatchMessagingService.swift:4`
|
||||
**Severity:** Low
|
||||
|
||||
**Issue:** `@preconcurrency` imports suppress sendability diagnostics for types from those modules. As Apple updates these frameworks for Sendable conformance in newer SDKs, the `@preconcurrency` should be removed to benefit from the compiler's checks.
|
||||
|
||||
**Recommendation:** Periodically check if these frameworks have been updated with Sendable annotations in newer Xcode versions and remove `@preconcurrency` when possible.
|
||||
|
||||
### L-2: `VoiceWakeManager.makeRecognitionResultHandler()` returns `@Sendable` closure that captures `[weak self]` correctly
|
||||
|
||||
**File:** `Voice/VoiceWakeManager.swift:301-313`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
The recognition result handler correctly captures `[weak self]` and hops to `@MainActor` before accessing any state. This is a good pattern.
|
||||
|
||||
### L-3: `CameraController` is an `actor` -- exemplary usage
|
||||
|
||||
**File:** `Camera/CameraController.swift:5`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
`CameraController` is the only `actor` in the codebase. It properly uses `nonisolated static` for pure functions and `async` for all state-mutating operations. This is a model for how other services could be structured.
|
||||
|
||||
### L-4: Several `Task { }` in `@MainActor` context don't explicitly annotate `@MainActor`
|
||||
|
||||
**Files:** Multiple
|
||||
**Severity:** Low
|
||||
|
||||
```swift
|
||||
// Inside @MainActor class:
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** In Swift 6.0, an unstructured `Task { }` created from `@MainActor` context inherits the actor context. However, in Swift 6.2 with `nonisolated(nonsending)` defaults, this behavior may change. Explicitly annotating `Task { @MainActor in }` makes the intent clear and forward-compatible.
|
||||
|
||||
**Recommendation:** Add explicit `@MainActor` annotation to `Task { }` blocks in `@MainActor` types where main-actor isolation is required.
|
||||
|
||||
### L-5: Consider migrating `NSLock` to `OSAllocatedUnfairLock` for better performance
|
||||
|
||||
**Files:** Multiple (6 usages)
|
||||
**Severity:** Low
|
||||
|
||||
`OSAllocatedUnfairLock` (available since iOS 16) is faster than `NSLock` for short critical sections. The existing `NSLock` usages in `AudioBufferQueue`, `NotificationInvokeLatch`, `CaptureState`, etc. are all protecting brief property accesses and would benefit from the switch.
|
||||
|
||||
**Recommendation:** Migrate `NSLock` to `OSAllocatedUnfairLock` where deployment target allows (iOS 16+). `TCPProbe.swift` already uses `OSAllocatedUnfairLock` -- apply the same pattern to other files.
|
||||
|
||||
### L-6: `NodeAppModel` is very large (~1500+ lines) which makes concurrency reasoning difficult
|
||||
|
||||
**File:** `Model/NodeAppModel.swift`
|
||||
**Severity:** Low (maintainability)
|
||||
|
||||
**Issue:** The large file size with many Task/async operations, multiple gateway sessions, and deeply nested closures makes it harder to reason about concurrency invariants. All state is `@MainActor` which is safe, but the complexity makes it harder to verify no accidental non-isolated access exists.
|
||||
|
||||
**Recommendation:** Consider splitting into smaller focused files (already noted with `NodeAppModel+Canvas.swift` and `NodeAppModel+WatchNotifyNormalization.swift` extensions). Further decomposition would improve auditability.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Found
|
||||
|
||||
1. **Consistent `@MainActor` + `@Observable` usage**: All major model types (`NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `TalkModeManager`, `VoiceWakeManager`, `ScreenController`) use the Observation framework with `@MainActor` isolation. Zero `ObservableObject` usages.
|
||||
|
||||
2. **Zero `@Sendable` protocol conformance issues**: All service protocols (`CameraServicing`, `LocationServicing`, `DeviceStatusServicing`, etc.) correctly require `Sendable`.
|
||||
|
||||
3. **`CameraController` as `actor`**: Properly models concurrent camera access.
|
||||
|
||||
4. **`@Sendable` closures in callback APIs**: Callback closures (e.g., `onCommand` in `VoiceWakeManager`, `replyHandler` in `WatchMessagingService`) are properly annotated `@Sendable`.
|
||||
|
||||
5. **`CheckedContinuation` usage**: All continuation usages properly handle the single-resume invariant with `didResume`/`finished` flags (though some lack synchronization -- see C-2 and M-5).
|
||||
|
||||
6. **No `DispatchQueue.main.async` for UI updates**: All UI-related state mutations go through `@MainActor` or `Task { @MainActor in }`, not legacy GCD patterns.
|
||||
|
||||
7. **`ThrowingContinuationSupport.resumeVoid`**: Custom helper for void continuations reduces boilerplate and potential mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6.2 / iOS 26 Forward-Compatibility Notes
|
||||
|
||||
1. **`nonisolated(nonsending)` default**: Several `nonisolated` functions and closures may need `@concurrent` annotation if they are intended to run off the caller's actor. Review all `nonisolated` methods.
|
||||
|
||||
2. **Default `@MainActor` isolation**: If the project opts into Swift 6.2's `MainActorByDefault`, most explicit `@MainActor` annotations become redundant. The current architecture is well-positioned for this.
|
||||
|
||||
3. **`@preconcurrency` removal**: As Apple frameworks adopt Sendable, remove `@preconcurrency` imports for `UserNotifications` and `WatchConnectivity`.
|
||||
|
||||
4. **`sending` parameter keyword**: New `sending` keyword in Swift 6.2 may replace some `@Sendable` closure annotations for parameters that are consumed (not stored).
|
||||
376
apps/ios/audit-security.md
Normal file
376
apps/ios/audit-security.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# iOS App Security, Networking & Performance Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/`, `apps/shared/OpenClawKit/Sources/` (security-relevant shared code), `apps/ios/project.yml`, entitlements
|
||||
**Auditor:** Security & Performance Audit Agent
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Posture Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a **generally strong security posture** for a local-network gateway client. Key strengths include:
|
||||
|
||||
- **Keychain usage for credentials:** Gateway tokens, passwords, instance IDs, and API keys are stored in Keychain (not UserDefaults).
|
||||
- **TLS certificate pinning:** SHA-256 certificate fingerprint pinning is implemented for gateway WebSocket connections via `GatewayTLSPinningSession`.
|
||||
- **Trust-on-first-use (TOFU) with user confirmation:** New gateway TLS fingerprints require explicit user approval before trust is established.
|
||||
- **Deep link confirmation:** Agent deep links (the `openclaw://` URL scheme) require user confirmation before execution, with message length limits.
|
||||
- **Web view security:** The canvas WKWebView uses `.nonPersistent()` data store and validates that A2UI action messages originate only from trusted/local-network URLs.
|
||||
- **Input sanitization:** Consistent `.trimmingCharacters(in: .whitespacesAndNewlines)` throughout, input length limits on contacts/calendar/photos queries.
|
||||
- **Permission gating:** All hardware capabilities (camera, location, microphone, contacts, calendar, photos) check authorization status before access.
|
||||
- **No hardcoded secrets:** No API keys, tokens, or credentials are hardcoded in the source.
|
||||
- **Swift 6 strict concurrency:** Enabled project-wide (`SWIFT_STRICT_CONCURRENCY: complete`), reducing data race risks.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Severity Findings
|
||||
|
||||
*No critical vulnerabilities identified.*
|
||||
|
||||
The app does not store plaintext passwords in UserDefaults, does not embed secrets, does not disable ATS globally, and does not allow arbitrary code execution from untrusted sources. The attack surface is primarily local-network, which limits remote exploitation vectors.
|
||||
|
||||
---
|
||||
|
||||
## 3. High Severity Findings
|
||||
|
||||
### H-1: TLS Fingerprints Stored in UserDefaults Instead of Keychain
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:19-38`
|
||||
**Severity:** HIGH
|
||||
|
||||
`GatewayTLSStore` stores TLS certificate fingerprints in `UserDefaults(suiteName: "ai.openclaw.shared")`. While fingerprints themselves are not secrets, they serve as the trust anchor for the TLS pinning system. An attacker with access to the device backup (unencrypted iTunes/Finder backup) or a compromised app extension sharing the same suite could modify these fingerprints and redirect gateway connections to a malicious server.
|
||||
|
||||
**Exploit scenario:** An attacker with physical or backup access modifies the stored fingerprint for a known gateway stableID, then performs a MITM attack on the LAN. The app connects using the attacker's fingerprint as the expected pin.
|
||||
|
||||
**Recommended fix:** Store TLS fingerprints in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (matching the existing `KeychainStore` pattern). This prevents backup extraction and cross-device compromise.
|
||||
|
||||
---
|
||||
|
||||
### H-2: KeychainStore Update Path Does Not Set Accessibility Level
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/KeychainStore.swift:20-37`
|
||||
**Severity:** HIGH
|
||||
|
||||
In `saveString()`, when the item already exists (`SecItemUpdate` succeeds), the update does not set or enforce the `kSecAttrAccessible` attribute. Only new items (via `SecItemAdd`) get `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. If a Keychain item was originally created with a less restrictive accessibility level (e.g., during a migration or by an older version), it retains that weaker level after updates.
|
||||
|
||||
**Exploit scenario:** An older app version or a migration path creates a Keychain item without specifying `kSecAttrAccessible` (defaults to `kSecAttrAccessibleWhenUnlocked`). After upgrading, the item retains the old accessibility level, potentially making it accessible via iCloud Keychain sync.
|
||||
|
||||
**Recommended fix:** Before `SecItemUpdate`, delete and re-add the item with the correct accessibility attribute, or explicitly include `kSecAttrAccessible` in the update query attributes. Example:
|
||||
|
||||
```swift
|
||||
// Delete-then-add pattern for consistent accessibility
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-3: Gateway Connection Metadata in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:170-217`
|
||||
**Severity:** HIGH
|
||||
|
||||
Last-known gateway connection details (host, port, TLS flag, stableID, connection kind) are stored in `UserDefaults.standard`. This data reveals which gateway servers the user connects to, their network topology, and connection preferences. UserDefaults are included in unencrypted device backups and can be read by MDM profiles or forensic tools.
|
||||
|
||||
**Affected keys:** `gateway.last.kind`, `gateway.last.host`, `gateway.last.port`, `gateway.last.tls`, `gateway.last.stableID`, `gateway.manual.host`, `gateway.manual.port`, `gateway.manual.tls`, `gateway.manual.clientId`, `gateway.clientIdOverride.*`, `gateway.selectedAgentId.*`.
|
||||
|
||||
**Recommended fix:** Move gateway connection metadata that reveals network topology to Keychain or use `NSFileProtectionCompleteUntilFirstUserAuthentication` on a dedicated plist file in the app's data directory.
|
||||
|
||||
---
|
||||
|
||||
## 4. Medium Severity Findings
|
||||
|
||||
### M-1: `NSAllowsArbitraryLoadsInWebContent` Enabled
|
||||
|
||||
**File:** `apps/ios/project.yml:110`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```yaml
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
```
|
||||
|
||||
This disables ATS protections for WKWebView content. While necessary for the canvas to load user-specified URLs from the gateway (including local-network HTTP servers), it means the web view can load insecure HTTP resources. The `ScreenController.navigate()` method does filter out loopback URLs but does not enforce HTTPS for remote URLs.
|
||||
|
||||
**Exploit scenario:** A gateway instructs the canvas to load an HTTP URL on a public network. The content is intercepted/modified via MITM.
|
||||
|
||||
**Recommended fix:** This is largely an accepted risk given the product's design (canvas loads gateway-specified URLs). Consider adding a user-visible indicator when the canvas is loading non-HTTPS content, and log a warning.
|
||||
|
||||
---
|
||||
|
||||
### M-2: Diagnostic Log File Written to Documents Directory Without Protection
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:359-448`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`GatewayDiagnostics` writes logs to `Documents/openclaw-gateway.log`. The Documents directory is accessible via iTunes file sharing (if enabled) and is included in device backups. Log entries include timestamps and gateway connection events which could reveal usage patterns.
|
||||
|
||||
Logs are written with `privacy: .public` in the `os.Logger` calls, meaning they are also visible in `Console.app` sysdiagnose captures without redaction.
|
||||
|
||||
**Recommended fix:** Write diagnostic logs to `Library/Caches/` instead (excluded from backups), apply `NSFileProtectionCompleteUntilFirstUserAuthentication`, and consider using `privacy: .private` or `privacy: .auto` for log messages that may contain sensitive connection details.
|
||||
|
||||
---
|
||||
|
||||
### M-3: Environment Variable Fallback for ElevenLabs API Key
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/TalkModeManager.swift:991-992`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```swift
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
```
|
||||
|
||||
The talk mode manager reads API keys from environment variables as a fallback. While environment variables are not accessible to other apps on iOS, they persist in process memory and could be captured in crash reports. This pattern is more suitable for development/debugging and should not ship in production builds.
|
||||
|
||||
**Recommended fix:** Gate this fallback behind `#if DEBUG` to prevent production builds from reading API keys from environment variables.
|
||||
|
||||
---
|
||||
|
||||
### M-4: Instance ID Dual-Storage Creates Sync Risk
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:291-312`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`ensureStableInstanceID()` maintains the instance ID in both Keychain and UserDefaults (`node.instanceId`). If either store is cleared independently (e.g., Keychain reset during device restore without backup, or UserDefaults cleared by storage pressure), the sync logic may create a new UUID, effectively orphaning the device's gateway registration.
|
||||
|
||||
While this is a robustness concern rather than a direct vulnerability, an attacker who can clear UserDefaults (e.g., via an MDM-deployed configuration profile) could force a device identity reset.
|
||||
|
||||
**Recommended fix:** Designate Keychain as the single source of truth and only mirror to UserDefaults for read convenience. Document the recovery flow for identity reset.
|
||||
|
||||
---
|
||||
|
||||
### M-5: No Rate Limiting on Deep Link Agent Prompts
|
||||
|
||||
**File:** `apps/ios/Sources/Model/NodeAppModel.swift:43-45, 92`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The `IOSDeepLinkAgentPolicy` defines `maxMessageChars = 20000` and `maxUnkeyedConfirmChars = 240`, and there is a `lastAgentDeepLinkPromptAt` timestamp. However, without a minimum interval check, a malicious webpage or app could rapidly fire `openclaw://` deep links, creating a flood of confirmation dialogs that degrade UX and potentially trick users into accepting a malicious prompt through fatigue.
|
||||
|
||||
**Recommended fix:** Enforce a minimum interval (e.g., 5 seconds) between successive deep link prompts, silently dropping duplicates. The `lastAgentDeepLinkPromptAt` field exists but its enforcement should be verified.
|
||||
|
||||
---
|
||||
|
||||
### M-6: QR Code Parsing Accepts Multiple Formats Without Strict Validation
|
||||
|
||||
**File:** `apps/ios/Sources/Onboarding/QRScannerView.swift:63-85`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The QR scanner tries two parsing strategies: `GatewayConnectDeepLink.fromSetupCode(payload)` (base64url JSON) and `DeepLinkParser.parse(url)` (URL format). The `GatewaySetupCode.decode()` method (`apps/ios/Sources/Gateway/GatewaySetupCode.swift`) accepts arbitrary base64-encoded JSON payloads that decode into `GatewaySetupPayload`. There is no signature verification or HMAC on the QR code content.
|
||||
|
||||
**Exploit scenario:** An attacker places a malicious QR code that encodes a gateway URL pointing to their controlled server. When scanned, the user is prompted to connect to the attacker's gateway.
|
||||
|
||||
**Mitigating factors:** The TLS trust prompt still fires for new gateways, requiring explicit user approval of the certificate fingerprint.
|
||||
|
||||
**Recommended fix:** Consider adding an HMAC or signing mechanism to QR setup codes so the app can verify they were generated by the user's own gateway. At minimum, clearly display the gateway URL/host to the user during the onboarding flow.
|
||||
|
||||
---
|
||||
|
||||
### M-7: WebSocket Maximum Message Size Set to 16 MB
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:55`
|
||||
**Severity:** MEDIUM (Performance/DoS)
|
||||
|
||||
```swift
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
```
|
||||
|
||||
A malicious or compromised gateway could send a 16 MB WebSocket message, causing a significant memory spike on the iOS device.
|
||||
|
||||
**Recommended fix:** Evaluate whether 16 MB is necessary. Consider progressive parsing or streaming for large payloads. Add a sanity check on incoming message size.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low Severity Findings
|
||||
|
||||
### L-1: Location Data Sent Over Gateway WebSocket Without End-to-End Encryption
|
||||
|
||||
**File:** `apps/ios/Sources/Location/SignificantLocationMonitor.swift:21-38`
|
||||
**Severity:** LOW
|
||||
|
||||
Significant location updates (lat, lon, accuracy) are sent as JSON over the gateway WebSocket. While the WebSocket uses TLS (wss://), the gateway server itself can read the location data in plaintext. This is by design (the gateway processes location for hooks), but users should be informed that location data is accessible to the gateway process.
|
||||
|
||||
**Recommended fix:** Document this clearly in privacy documentation. Consider allowing users to configure location precision (rounding to neighborhood-level vs. exact coordinates).
|
||||
|
||||
---
|
||||
|
||||
### L-2: Camera Photo/Video Base64 Encoding in Memory
|
||||
|
||||
**Files:** `apps/ios/Sources/Camera/CameraController.swift:84`, `apps/ios/Sources/Media/PhotoLibraryService.swift:105`
|
||||
**Severity:** LOW
|
||||
|
||||
Camera captures and photo library images are base64-encoded in memory before being sent over the gateway. For large images (up to 1600px wide at 0.9 quality), this means the raw image data plus the base64 string (33% larger) coexist in memory briefly.
|
||||
|
||||
**Mitigating factors:** The app already clamps max width to 1600px and applies quality compression. Temporary files are cleaned up via `defer` blocks.
|
||||
|
||||
**Recommended fix:** Consider streaming base64 encoding or using a memory-mapped approach for very large payloads. Current implementation is adequate for the existing size limits.
|
||||
|
||||
---
|
||||
|
||||
### L-3: Screen Recording Output Path User-Controllable
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift:103-109`
|
||||
**Severity:** LOW
|
||||
|
||||
The `makeOutputURL` method accepts an optional `outPath` parameter. If this comes from a gateway command, a malicious gateway could specify a path outside the app's sandbox (which iOS would block) or overwrite files within the sandbox.
|
||||
|
||||
**Mitigating factors:** iOS sandbox prevents writing outside the app container. The `defer` cleanup in the caller should handle temporary files.
|
||||
|
||||
**Recommended fix:** Validate that `outPath` is within the app's temporary or documents directory before using it. Reject absolute paths that don't start with the app's known writable directories.
|
||||
|
||||
---
|
||||
|
||||
### L-4: Voice Wake Preferences Stored in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakePreferences.swift:23-29`
|
||||
**Severity:** LOW
|
||||
|
||||
Trigger words and voice wake enabled state are stored in `UserDefaults.standard`. While trigger words are not sensitive per se, they reveal user behavior patterns.
|
||||
|
||||
**Recommended fix:** Acceptable for non-sensitive preferences. No action needed unless trigger words become user-configurable sensitive phrases.
|
||||
|
||||
---
|
||||
|
||||
### L-5: `nonisolated(unsafe)` in GatewayDiagnostics
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** LOW
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
This counter is accessed from the `DispatchQueue` without the lock that protects the file I/O. While this is a benign data race (used only for approximate frequency gating), it could theoretically cause the log size check to be skipped or double-triggered.
|
||||
|
||||
**Recommended fix:** Move the counter into the `queue.async` block or use an atomic counter.
|
||||
|
||||
---
|
||||
|
||||
### L-6: `objc_sync_enter` Used for Synchronization
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayTLSFingerprintProbe.finish()` uses `objc_sync_enter/exit` for synchronization. This is the Objective-C `@synchronized` primitive. While functional, modern Swift best practice prefers `OSAllocatedUnfairLock` (as used correctly in `TCPProbe`), `NSLock`, or actor isolation.
|
||||
|
||||
**Recommended fix:** Replace with `OSAllocatedUnfairLock` for consistency with the rest of the codebase.
|
||||
|
||||
---
|
||||
|
||||
### L-7: No Certificate Revocation Checking
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:59-96`
|
||||
**Severity:** LOW
|
||||
|
||||
The TLS pinning implementation checks the certificate fingerprint but does not perform OCSP or CRL revocation checking. For self-signed certificates (typical in local gateway setups), this is expected. For publicly-signed certificates, revocation checking would add defense in depth.
|
||||
|
||||
**Recommended fix:** For the current use case (self-signed gateway certs on LAN), this is acceptable. If public CA certificates are used in future, consider enabling revocation checking via `SecTrustSetOptions`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Concerns
|
||||
|
||||
### P-1: ISO8601DateFormatter Created Per Log Entry
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:422-424`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayDiagnostics.log()` creates a new `ISO8601DateFormatter` for every log call. `ISO8601DateFormatter` is relatively expensive to initialize.
|
||||
|
||||
**Recommended fix:** Cache a static formatter instance (thread-safety is acceptable for `ISO8601DateFormatter` as it is immutable after configuration).
|
||||
|
||||
---
|
||||
|
||||
### P-2: Observation Tracking Re-registration Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** LOW
|
||||
|
||||
The `observeDiscovery()` method uses `withObservationTracking` and recursively calls itself in the `onChange` closure. This is the standard Swift Observation pattern, but each change creates a new `Task` and re-registers tracking. Under rapid discovery state changes, this could create a burst of Task allocations.
|
||||
|
||||
**Mitigating factors:** Discovery state changes are infrequent (Bonjour events).
|
||||
|
||||
**Recommended fix:** Consider debouncing or coalescing rapid state changes.
|
||||
|
||||
---
|
||||
|
||||
### P-3: Synchronous Photo Library Access
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift:69-71`
|
||||
**Severity:** MEDIUM (Performance)
|
||||
|
||||
```swift
|
||||
options.isSynchronous = true
|
||||
```
|
||||
|
||||
`PHImageManager.requestImage` is called synchronously, which blocks the calling thread until the image is loaded and decoded. For network-backed assets (iCloud Photo Library), this could block for seconds.
|
||||
|
||||
**Recommended fix:** Use asynchronous image loading with a continuation wrapper to avoid blocking.
|
||||
|
||||
---
|
||||
|
||||
### P-4: Camera Clip Base64 Encoding of Video Data
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift:89-140`
|
||||
**Severity:** LOW
|
||||
|
||||
Video clips (up to 60 seconds) are fully loaded into memory as `Data` and then base64-encoded. A 60-second medium-quality MP4 could be 10-30 MB, producing a 13-40 MB base64 string in memory.
|
||||
|
||||
**Mitigating factors:** Default duration is 3 seconds, keeping typical payloads small. The 60-second max is enforced at `CameraController.clampDurationMs`.
|
||||
|
||||
**Recommended fix:** Consider a streaming upload mechanism for clips longer than ~10 seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7. OWASP Mobile Top 10 2024 Checklist
|
||||
|
||||
| # | OWASP Category | Status | Notes |
|
||||
|---|---------------|--------|-------|
|
||||
| M1 | Improper Credential Usage | **PASS** | Credentials stored in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. No hardcoded secrets. API keys from env vars gated to dev builds (recommended). |
|
||||
| M2 | Inadequate Supply Chain Security | **PASS** | Dependencies are version-pinned via Package.resolved. SwiftLint and SwiftFormat enforce code quality. |
|
||||
| M3 | Insecure Authentication/Authorization | **PASS** | Gateway authentication uses token + password stored in Keychain. TLS pinning prevents MITM. Deep links require user confirmation. |
|
||||
| M4 | Insufficient Input/Output Validation | **PASS** | Input trimming and length limits applied consistently. QR code parsing has two validated paths. Calendar/contacts sanitize inputs. |
|
||||
| M5 | Insecure Communication | **PASS with notes** | TLS required for non-loopback connections. Certificate pinning implemented. `NSAllowsArbitraryLoadsInWebContent` allows HTTP in web views (accepted risk for canvas). |
|
||||
| M6 | Inadequate Privacy Controls | **PASS with notes** | All sensitive permissions have usage descriptions. Location data sent to gateway in plaintext over TLS. Photo library access checks authorization. Logging uses `privacy: .public` for some potentially sensitive data. |
|
||||
| M7 | Insufficient Binary Protections | **N/A** | Standard Xcode compilation. No jailbreak detection implemented (acceptable for non-financial app). |
|
||||
| M8 | Security Misconfiguration | **PASS with notes** | TLS fingerprints in UserDefaults (H-1). Gateway metadata in UserDefaults (H-3). Entitlements minimal (only `aps-environment`). |
|
||||
| M9 | Insecure Data Storage | **PASS with notes** | Credentials in Keychain (good). Gateway connection metadata in UserDefaults (H-3). Diagnostic logs in Documents directory (M-2). |
|
||||
| M10 | Insufficient Cryptography | **PASS** | SHA-256 for certificate fingerprinting via CryptoKit. No custom/weak crypto implementations. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of Recommendations by Priority
|
||||
|
||||
### Immediate (High)
|
||||
1. **H-1:** Move TLS fingerprint storage from UserDefaults to Keychain
|
||||
2. **H-2:** Fix KeychainStore to enforce accessibility level on updates (delete + re-add)
|
||||
3. **H-3:** Move gateway connection metadata out of UserDefaults
|
||||
|
||||
### Short-term (Medium)
|
||||
4. **M-3:** Gate `ELEVENLABS_API_KEY` env var fallback behind `#if DEBUG`
|
||||
5. **M-2:** Move diagnostic logs to Caches directory, apply file protection
|
||||
6. **M-5:** Enforce minimum interval between deep link prompts
|
||||
7. **M-6:** Add HMAC/signature to QR setup codes
|
||||
8. **M-7:** Evaluate reducing WebSocket max message size from 16 MB
|
||||
9. **P-3:** Convert synchronous photo library loading to async
|
||||
|
||||
### Long-term (Low / Hardening)
|
||||
10. **L-6:** Replace `objc_sync_enter` with `OSAllocatedUnfairLock`
|
||||
11. **L-3:** Validate screen recording output paths
|
||||
12. **P-1:** Cache ISO8601DateFormatter instances
|
||||
13. **M-1:** Add indicator for non-HTTPS canvas content
|
||||
14. **L-5:** Fix `nonisolated(unsafe)` data race in log counter
|
||||
|
||||
---
|
||||
|
||||
## 9. Positive Security Patterns Worth Preserving
|
||||
|
||||
- **TOFU with explicit user confirmation** for TLS fingerprints is a pragmatic and user-friendly approach for self-signed certificates.
|
||||
- **Dual WebSocket sessions** (node + operator) with separate role scoping provides good privilege separation.
|
||||
- **`websiteDataStore = .nonPersistent()`** for the canvas WKWebView prevents session data leakage.
|
||||
- **Origin validation in `CanvasA2UIActionMessageHandler`** (checking `isTrustedCanvasUIURL` and `isLocalNetworkCanvasURL`) is a strong defense against arbitrary web content triggering actions.
|
||||
- **Loopback URL rejection** in `ScreenController.navigate()` prevents SSRF-like attacks from the gateway.
|
||||
- **Autoconnect only to previously trusted gateways** (stored TLS pin required) prevents connecting to rogue gateways after TOFU.
|
||||
- **Permission checks before hardware access** with clear error messages is well-implemented.
|
||||
- **`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`** is the correct Keychain accessibility level for this use case (device-local, available after first unlock for background operation).
|
||||
318
apps/ios/audit-uiux.md
Normal file
318
apps/ios/audit-uiux.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# iOS App UI/UX & Accessibility Audit
|
||||
|
||||
**Audit Date:** 2026-03-02
|
||||
**Scope:** All SwiftUI view files in `apps/ios/Sources/`
|
||||
**Reference Standards:** Apple HIG (iOS 26), WCAG 2.1 AA, Liquid Glass design language, SwiftUI accessibility best practices
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Health Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a well-structured SwiftUI codebase with several accessibility-conscious patterns already in place. The app uses the modern `@Observable` / `Observation` framework consistently, respects `accessibilityReduceMotion`, responds to `colorSchemeContrast`, and provides accessibility labels on key interactive elements. However, there are significant gaps in Dynamic Type support, localization readiness, haptic feedback, and iPad adaptivity that should be addressed before the next major release.
|
||||
|
||||
**Strengths:**
|
||||
- Good use of `@Environment(\.accessibilityReduceMotion)` in animation-heavy views (RootTabs, StatusPill)
|
||||
- `StatusGlassCard` correctly responds to `colorSchemeContrast` for increased visibility
|
||||
- `StatusPill` has proper `accessibilityLabel`, `accessibilityValue`, and `accessibilityHint`
|
||||
- `TalkOrbOverlay` uses `accessibilityElement(children: .combine)` to present a single VoiceOver element
|
||||
- Consistent use of `@Observable` macro (Observation framework) over legacy `ObservableObject`
|
||||
- Glass material effects on overlays (`.ultraThinMaterial`) with light/dark mode awareness
|
||||
|
||||
**Weaknesses:**
|
||||
- Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize` environment usage)
|
||||
- Zero localization infrastructure (no `NSLocalizedString`, `String(localized:)`, or `.strings` files)
|
||||
- Zero haptic feedback across the entire app
|
||||
- Several views lack accessibility labels entirely
|
||||
- Hardcoded dimensions in TalkOrbOverlay will break on small screens
|
||||
- SettingsTab is a monolithic ~650 LOC file
|
||||
- No iPad-specific layout adaptations
|
||||
- `RootCanvas` voiceWakeToast animation does not respect `reduceMotion` (unlike `RootTabs`)
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: RootCanvas animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:159-167`
|
||||
**Description:** The `voiceWakeToastText` animation in `RootCanvas` uses hardcoded `.spring()` and `.easeOut()` animations without checking `@Environment(\.accessibilityReduceMotion)`. The sibling `RootTabs` view correctly guards the same toast animation with `reduceMotion ? .none : .spring(...)`.
|
||||
|
||||
**Impact:** Users who require reduced motion will see unexpected animations in the canvas view.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// In RootCanvas, add the environment property:
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Then guard animations:
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
// ...
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
```
|
||||
|
||||
### C-2: TalkOrbOverlay perpetual animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:15-26`
|
||||
**Description:** The pulsing ring animations use `.repeatForever(autoreverses: false)` without checking `reduceMotion`. These are high-frequency, continuous animations that can cause discomfort for users with vestibular disorders.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Replace pulse animations with:
|
||||
if !reduceMotion {
|
||||
Circle()
|
||||
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||
}
|
||||
```
|
||||
|
||||
### C-3: CameraFlashOverlay has no accessibility announcement
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:405-429`
|
||||
**Description:** `CameraFlashOverlay` flashes the screen white at 85% opacity. VoiceOver users have no indication that a photo was taken. There is no `AccessibilityNotification.Announcement` posted, and the flash itself could trigger photosensitive reactions.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// Post an accessibility announcement:
|
||||
AccessibilityNotification.Announcement("Photo captured").post()
|
||||
|
||||
// Add prefers-reduced-motion check to skip or soften the flash:
|
||||
if reduceMotion {
|
||||
// Skip flash, or use subtle opacity change
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Zero Dynamic Type support across the entire app
|
||||
|
||||
**Files:** All view files in `Sources/`
|
||||
**Description:** No view uses `@ScaledMetric`, `@Environment(\.dynamicTypeSize)`, or `ContentSizeCategory`. All hardcoded font sizes and dimensions (e.g., `font(.system(size: 16))` in `OverlayButton`, `font(.system(size: 12))` in monospaced debug text, `frame(width: 320, height: 320)` in TalkOrbOverlay) will not scale with the user's preferred text size. Apple's HIG strongly recommends supporting Dynamic Type for all text.
|
||||
|
||||
**Key locations:**
|
||||
- `Sources/RootCanvas.swift:358` - OverlayButton uses fixed `size: 16`
|
||||
- `Sources/Voice/TalkOrbOverlay.swift:16,23,39` - Fixed 320pt and 190pt circles
|
||||
- `Sources/Status/StatusPill.swift:52` - Fixed `width: 9, height: 9` indicator dot
|
||||
- `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:24` - Fixed `font(.callout)`
|
||||
- `Sources/Gateway/GatewayOnboardingView.swift:345-346` - Fixed `.system(size: 12)` monospaced text
|
||||
|
||||
**Recommended Fix:** Use semantic font styles (`.body`, `.headline`, etc.) instead of fixed sizes where possible. For custom dimensions, use `@ScaledMetric`:
|
||||
```swift
|
||||
@ScaledMetric(relativeTo: .body) private var orbSize: CGFloat = 190
|
||||
@ScaledMetric(relativeTo: .caption) private var dotSize: CGFloat = 9
|
||||
```
|
||||
|
||||
### H-2: OnboardingWizardView missing accessibility labels on interactive elements
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
**Description:** Multiple interactive elements lack accessibility labels:
|
||||
- `OnboardingModeRow` (line 861-884): Radio-style selection buttons have no `accessibilityAddTraits(.isButton)` or clear selection state announcement. VoiceOver users cannot tell which mode is selected.
|
||||
- Gateway list connect buttons (line 453-465): `ProgressView` and "Resolving..." text lack accessibility context.
|
||||
- QR scanner action (line 319-326): "Scan QR Code" button label is good, but the status line below it (line 340-345) is not connected as an accessibility value.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// OnboardingModeRow:
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
|
||||
.accessibilityLabel("\(self.title), \(self.subtitle)")
|
||||
.accessibilityValue(self.selected ? "Selected" : "Not selected")
|
||||
```
|
||||
|
||||
### H-3: No localization infrastructure
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** The entire app uses hardcoded English strings with no localization wrapping. No `NSLocalizedString`, `String(localized:)`, `.strings`/`.stringsdict` files, or `LocalizedStringKey` usage was found. This makes the app inaccessible to non-English speakers and violates Apple's HIG recommendation to support multiple languages.
|
||||
|
||||
**Key examples:**
|
||||
- `Sources/Settings/SettingsTab.swift`: All section headers, labels, help text
|
||||
- `Sources/Onboarding/OnboardingWizardView.swift`: "Welcome", "Connected", all step descriptions
|
||||
- `Sources/Status/StatusPill.swift`: "Connected", "Connecting...", "Error", "Offline"
|
||||
- `Sources/Voice/VoiceTab.swift`: All list labels
|
||||
|
||||
**Recommended Fix:** Wrap all user-facing strings in `String(localized:)` or use `LocalizedStringResource`. Create a `Localizable.xcstrings` catalog.
|
||||
|
||||
### H-4: No haptic feedback anywhere in the app
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** No `UIImpactFeedbackGenerator`, `UINotificationFeedbackGenerator`, `UISelectionFeedbackGenerator`, or `.sensoryFeedback()` modifier usage found. Key interaction points that would benefit from haptics:
|
||||
- Gateway connection success/failure
|
||||
- Voice wake trigger detection
|
||||
- Talk mode orb tap
|
||||
- QR code successfully scanned
|
||||
- Toggle state changes in Settings
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// iOS 17+ SwiftUI modifier:
|
||||
.sensoryFeedback(.success, trigger: appModel.gatewayServerName != nil)
|
||||
|
||||
// For Talk orb tap:
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: tapCount)
|
||||
```
|
||||
|
||||
### H-5: GatewayTrustPromptAlert uses deprecated `Alert` API
|
||||
|
||||
**File:** `Sources/Gateway/GatewayTrustPromptAlert.swift:17-35`
|
||||
**Description:** Uses the deprecated `Alert(title:message:primaryButton:secondaryButton:)` initializer pattern. This API was deprecated in iOS 15 in favor of the `alert(_:isPresented:actions:message:)` modifier. Same issue in `DeepLinkAgentPromptAlert.swift:15-33`.
|
||||
|
||||
**Recommended Fix:** Migrate to the modern `alert` modifier with `@ViewBuilder` actions.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: SettingsTab is a monolithic view (~650+ LOC)
|
||||
|
||||
**File:** `Sources/Settings/SettingsTab.swift`
|
||||
**Description:** SettingsTab contains the entire settings UI, including gateway connection, device features, advanced debug options, agent picker, and reset logic. The file has a `// swiftlint:disable type_body_length` comment acknowledging this. This makes the view hard to maintain and test.
|
||||
|
||||
**Recommended Fix:** Extract into focused sub-views:
|
||||
- `GatewaySettingsSection`
|
||||
- `DeviceFeaturesSection`
|
||||
- `AdvancedSettingsSection`
|
||||
- `DeviceInfoSection`
|
||||
|
||||
### M-2: No empty states for VoiceTab when disconnected
|
||||
|
||||
**File:** `Sources/Voice/VoiceTab.swift`
|
||||
**Description:** VoiceTab always shows the same status labels regardless of gateway connection state. When disconnected, it should show a clear empty state explaining that voice features require a gateway connection, with a CTA to connect.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
if appModel.gatewayServerName == nil {
|
||||
ContentUnavailableView(
|
||||
"Not Connected",
|
||||
systemImage: "antenna.radiowaves.left.and.right.slash",
|
||||
description: Text("Connect to a gateway to use voice features."))
|
||||
}
|
||||
```
|
||||
|
||||
### M-3: No loading/error states in GatewayQuickSetupSheet
|
||||
|
||||
**File:** `Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
**Description:** When `bestCandidate` is nil and no gateways are found, the sheet shows a text message but no visual indicator that discovery is actively running. No retry button or activity indicator is shown during the discovery phase.
|
||||
|
||||
### M-4: OverlayButton touch target may be too small
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:348-403`
|
||||
**Description:** `OverlayButton` uses `padding(10)` around a 16pt icon, resulting in a ~36pt touch target. Apple HIG recommends a minimum of 44pt x 44pt for touch targets.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
// or increase padding to at least 14pt
|
||||
```
|
||||
|
||||
### M-5: No keyboard shortcut support
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.keyboardShortcut()` modifiers found anywhere. iPad users with external keyboards have no keyboard navigation shortcuts for common actions like opening chat, settings, or toggling voice.
|
||||
|
||||
### M-6: TalkOrbOverlay hardcoded dimensions break on small screens
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:16,23,39`
|
||||
**Description:** The pulse rings are hardcoded at 320pt width/height, and the inner orb at 190pt. On iPhone SE (320pt logical width), the rings will extend beyond screen bounds. On iPad, the orb will appear relatively small.
|
||||
|
||||
**Recommended Fix:** Use `GeometryReader` or `@ScaledMetric` for adaptive sizing:
|
||||
```swift
|
||||
GeometryReader { proxy in
|
||||
let size = min(proxy.size.width, proxy.size.height) * 0.65
|
||||
Circle().frame(width: size, height: size)
|
||||
}
|
||||
```
|
||||
|
||||
### M-7: ScreenTab error overlay not accessible
|
||||
|
||||
**File:** `Sources/Screen/ScreenTab.swift:12-21`
|
||||
**Description:** The error text overlay appears only when `errorText` is set and the gateway is disconnected, but there is no VoiceOver announcement when the error appears or disappears. Screen reader users may not notice the error.
|
||||
|
||||
### M-8: No pull-to-refresh on any list view
|
||||
|
||||
**Files:** `Sources/Voice/VoiceTab.swift`, `Sources/Gateway/GatewayDiscoveryDebugLogView.swift`
|
||||
**Description:** List views do not support `.refreshable {}` for pull-to-refresh, which is a standard iOS interaction pattern.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: Inconsistent glass card styling between RootTabs and RootCanvas
|
||||
|
||||
**Files:** `Sources/RootTabs.swift`, `Sources/RootCanvas.swift`
|
||||
**Description:** `RootTabs` shows `StatusPill` without the `brighten` parameter (defaults to false), while `RootCanvas.CanvasContent` passes `brighten` based on color scheme. This can cause visual inconsistency if both code paths are reachable.
|
||||
|
||||
### L-2: VoiceWakeToast hardcoded top offset
|
||||
|
||||
**Files:** `Sources/RootTabs.swift:47`, `Sources/RootCanvas.swift:329`
|
||||
**Description:** `.safeAreaPadding(.top, 58)` is a magic number that assumes the StatusPill height. If the pill height changes (e.g., with Dynamic Type), the toast will overlap.
|
||||
|
||||
### L-3: No app-wide tint/accent color configuration
|
||||
|
||||
**Files:** `Sources/OpenClawApp.swift`
|
||||
**Description:** No `.tint()` or `accentColor` is set at the app level. The default blue accent is used for buttons and toggles, but the app uses `appModel.seamColor` for some elements. This creates visual inconsistency.
|
||||
|
||||
### L-4: ConnectionStatusBox uses hardcoded monospaced font size
|
||||
|
||||
**File:** `Sources/Onboarding/GatewayOnboardingView.swift:345-346`
|
||||
**Description:** `.font(.system(size: 12, weight: .regular, design: .monospaced))` will not scale with Dynamic Type.
|
||||
|
||||
### L-5: DateFormatter instances in GatewayDiscoveryDebugLogView are not locale-aware
|
||||
|
||||
**File:** `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:49-53`
|
||||
**Description:** `DateFormatter` with hardcoded `dateFormat = "HH:mm:ss"` does not respect the user's locale for time formatting. Should use `.dateStyle`/`.timeStyle` or `formatted()`.
|
||||
|
||||
### L-6: No transition animations on sheet presentations
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:92-111`
|
||||
**Description:** The `.sheet(item:)` presentations for settings, chat, and quick setup use default sheet transitions. Custom `presentationDetents` could improve the UX for smaller sheets like Quick Setup.
|
||||
|
||||
### L-7: Onboarding wizard duplicate padding
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift:344-346`
|
||||
**Description:** The welcome step has duplicate `.padding(.horizontal, 24)` on the status line (lines 344 and 345), which doubles the intended padding.
|
||||
|
||||
### L-8: No VoiceOver rotor actions
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.accessibilityAction(named:)` or custom rotor items are defined. Power VoiceOver users could benefit from custom actions for common operations.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Compliance Checklist
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|---|---|---|
|
||||
| VoiceOver labels on all interactive elements | Partial | Overlay buttons, StatusPill, ChatSheet close, SettingsTab close have labels. OnboardingModeRow, gateway list items, many settings toggles missing. |
|
||||
| VoiceOver hints for non-obvious actions | Partial | StatusPill has hint. Most buttons lack hints. |
|
||||
| VoiceOver value for stateful elements | Partial | StatusPill has value. Toggle states auto-announced by SwiftUI. OnboardingModeRow selection not announced. |
|
||||
| Dynamic Type support | Missing | No `@ScaledMetric`, no `dynamicTypeSize` environment, fixed font sizes throughout. |
|
||||
| Reduce Motion respected | Partial | RootTabs and StatusPill respect it. RootCanvas, TalkOrbOverlay, CameraFlashOverlay do not. |
|
||||
| Increased Contrast support | Partial | StatusGlassCard adjusts border for increased contrast. Other views do not check. |
|
||||
| Color not sole indicator | Pass | Status uses both color dots and text labels. |
|
||||
| Minimum touch target 44pt | Partial | Standard buttons OK. OverlayButton (~36pt) and StatusPill dot are undersized. |
|
||||
| Keyboard navigation (iPad) | Missing | No keyboard shortcuts defined. |
|
||||
| Localization readiness | Missing | All strings hardcoded in English. |
|
||||
| Haptic feedback | Missing | No haptic feedback in any interaction. |
|
||||
| iPad layout adaptation | Missing | No `horizontalSizeClass` or iPad-specific layouts. |
|
||||
| Dark mode support | Pass | Uses semantic colors, materials, and `.preferredColorScheme(.dark)` for canvas. |
|
||||
| Safe area handling | Pass | Correct use of `.ignoresSafeArea()` for screen, `.safeAreaPadding()` for overlays. |
|
||||
| Error state announcements | Missing | No `AccessibilityNotification.Announcement` for state changes. |
|
||||
| Focus management | Partial | `@FocusState` used in VoiceWakeWordsSettingsView. No focus management in onboarding. |
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Key Themes |
|
||||
|---|---|---|
|
||||
| Critical | 3 | Reduce Motion violations, flash accessibility |
|
||||
| High | 5 | Dynamic Type, localization, haptics, deprecated APIs, missing labels |
|
||||
| Medium | 8 | Monolithic views, empty states, touch targets, iPad, hardcoded sizes |
|
||||
| Low | 8 | Styling consistency, magic numbers, locale formatting, polish |
|
||||
@@ -38,8 +38,6 @@ targets:
|
||||
dependencies:
|
||||
- target: OpenClawShareExtension
|
||||
embed: true
|
||||
- target: OpenClawActivityWidget
|
||||
embed: true
|
||||
- target: OpenClawWatchApp
|
||||
- package: OpenClawKit
|
||||
- package: OpenClawKit
|
||||
@@ -86,7 +84,6 @@ targets:
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
info:
|
||||
@@ -118,7 +115,6 @@ targets:
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
@@ -168,37 +164,6 @@ targets:
|
||||
NSExtensionActivationSupportsImageWithMaxCount: 10
|
||||
NSExtensionActivationSupportsMovieWithMaxCount: 1
|
||||
|
||||
OpenClawActivityWidget:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: ActivityWidget
|
||||
- path: Sources/LiveActivity/OpenClawActivityAttributes.swift
|
||||
dependencies:
|
||||
- sdk: WidgetKit.framework
|
||||
- sdk: ActivityKit.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
info:
|
||||
path: ActivityWidget/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Activity
|
||||
CFBundleShortVersionString: "2026.3.2"
|
||||
CFBundleVersion: "20260301"
|
||||
NSSupportsLiveActivities: true
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
|
||||
OpenClawWatchApp:
|
||||
type: application.watchapp2
|
||||
platform: watchOS
|
||||
|
||||
@@ -59,7 +59,7 @@ extension URLSession: WebSocketSessioning {
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.webSocketTask(with: url)
|
||||
// Avoid "Message too long" receive errors for large snapshots / history payloads.
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
task.maximumMessageSize = 4 * 1024 * 1024 // 4 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,13 @@ public enum GatewayTLSStore {
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
|
||||
let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let raw = self.keychainLoad(account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw?.isEmpty == false { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
|
||||
self.keychainSave(value, account: stableID)
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
@@ -46,13 +45,43 @@ public enum GatewayTLSStore {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
else { return }
|
||||
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
|
||||
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID) else {
|
||||
return
|
||||
}
|
||||
if self.keychainLoad(account: stableID) == nil {
|
||||
guard self.keychainSave(existing, account: stableID) else { return }
|
||||
}
|
||||
defaults.removeObject(forKey: legacyKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-contained Keychain helpers (OpenClawKit can't import iOS KeychainStore)
|
||||
|
||||
private static func keychainLoad(account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func keychainSave(_ value: String, account: String) -> Bool {
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Delete-then-add to enforce accessibility attribute.
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
@@ -70,7 +99,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
task.maximumMessageSize = 4 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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,7 +12,6 @@ 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>?
|
||||
|
||||
@@ -27,23 +26,17 @@ 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,
|
||||
onStart: (() -> Void)? = nil
|
||||
) async throws {
|
||||
public func speak(text: String, language: String? = 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) {
|
||||
@@ -83,13 +76,8 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
private func handleFinish(error: Error?) {
|
||||
guard self.currentUtterance != nil else { return }
|
||||
self.watchdog?.cancel()
|
||||
self.watchdog = nil
|
||||
self.finishCurrent(with: error)
|
||||
@@ -97,7 +85,6 @@ 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 {
|
||||
@@ -109,26 +96,12 @@ 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(utteranceID: utteranceID, error: nil)
|
||||
self.handleFinish(error: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +109,8 @@ extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate {
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didCancel utterance: AVSpeechUtterance)
|
||||
{
|
||||
let utteranceID = ObjectIdentifier(utterance)
|
||||
Task { @MainActor in
|
||||
self.handleFinish(utteranceID: utteranceID, error: SpeakError.canceled)
|
||||
self.handleFinish(error: SpeakError.canceled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- Feishu reply routing now uses one canonical reply-target path across inbound and outbound flows: normal groups reply to the triggering message while topic-mode groups stay on topic roots, outbound sends preserve `replyToId`/`threadId`, withdrawn reply targets fall back to direct sends, and cron duplicate suppression normalizes Feishu/Lark target IDs consistently (#32980, #32958, #33572, #33526; #33789, #33575, #33515, #33161). Thanks @guoqunabc, @bmendonca3, @MunemHashmi, and @Jimmy-xuzimo.
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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.
|
||||
@@ -8,7 +8,7 @@ title: "Brave Search"
|
||||
|
||||
# Brave Search API
|
||||
|
||||
OpenClaw supports Brave Search as a web search provider for `web_search`.
|
||||
OpenClaw uses Brave Search as the default provider for `web_search`.
|
||||
|
||||
## Get an API key
|
||||
|
||||
@@ -33,48 +33,10 @@ OpenClaw supports Brave Search as a web search provider for `web_search`.
|
||||
}
|
||||
```
|
||||
|
||||
## Tool parameters
|
||||
|
||||
| Parameter | Description |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `query` | Search query (required) |
|
||||
| `count` | Number of results to return (1-10, default: 5) |
|
||||
| `country` | 2-letter ISO country code (e.g., "US", "DE") |
|
||||
| `language` | ISO 639-1 language code for search results (e.g., "en", "de", "fr") |
|
||||
| `ui_lang` | ISO language code for UI elements |
|
||||
| `freshness` | Time filter: `day` (24h), `week`, `month`, or `year` |
|
||||
| `date_after` | Only results published after this date (YYYY-MM-DD) |
|
||||
| `date_before` | Only results published before this date (YYYY-MM-DD) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// Country and language-specific search
|
||||
await web_search({
|
||||
query: "renewable energy",
|
||||
country: "DE",
|
||||
language: "de",
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "AI news",
|
||||
freshness: "week",
|
||||
});
|
||||
|
||||
// Date range search
|
||||
await web_search({
|
||||
query: "AI developments",
|
||||
date_after: "2024-01-01",
|
||||
date_before: "2024-06-30",
|
||||
});
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides paid plans; check the Brave API portal for current limits.
|
||||
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -133,8 +133,6 @@ 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>
|
||||
|
||||
@@ -421,7 +419,6 @@ Example:
|
||||
guilds: {
|
||||
"123456789012345678": {
|
||||
requireMention: true,
|
||||
ignoreOtherMentions: true,
|
||||
users: ["987654321098765432"],
|
||||
roles: ["123456789012345678"],
|
||||
channels: {
|
||||
@@ -449,7 +446,6 @@ 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:
|
||||
|
||||
@@ -790,7 +786,7 @@ Default slash command settings:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Presence configuration">
|
||||
Presence updates are applied when you set a status or activity field, or when you enable auto presence.
|
||||
Presence updates are applied only when you set a status or activity field.
|
||||
|
||||
Status only example:
|
||||
|
||||
@@ -840,29 +836,6 @@ 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">
|
||||
@@ -1084,7 +1057,6 @@ 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>
|
||||
|
||||
|
||||
@@ -445,29 +445,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- typing actions still include `message_thread_id`
|
||||
|
||||
Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`).
|
||||
`agentId` is topic-only and does not inherit from group defaults.
|
||||
|
||||
**Per-topic agent routing**: Each topic can route to a different agent by setting `agentId` in the topic config. This gives each topic its own isolated workspace, memory, and session. Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"-1001234567890": {
|
||||
topics: {
|
||||
"1": { agentId: "main" }, // General topic → main agent
|
||||
"3": { agentId: "zu" }, // Dev topic → zu agent
|
||||
"5": { agentId: "coder" } // Code review → coder agent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each topic then has its own session key: `agent:zu:telegram:group:-1001234567890:topic:3`
|
||||
|
||||
Template context includes:
|
||||
|
||||
@@ -774,11 +751,9 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
|
||||
- `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
|
||||
@@ -114,8 +114,6 @@ 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,10 +73,7 @@ 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. When truncation
|
||||
occurs, OpenClaw can inject a warning block in Project Context; control this with
|
||||
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
|
||||
default: `once`).
|
||||
(default: 150000). Missing files inject a short missing-file marker.
|
||||
|
||||
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
|
||||
are filtered out to keep the sub-agent context small).
|
||||
|
||||
@@ -1182,7 +1182,6 @@
|
||||
"gateway/configuration-reference",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"auth-credential-semantics",
|
||||
"gateway/secrets",
|
||||
"gateway/secrets-plan-contract",
|
||||
"gateway/trusted-proxy-auth",
|
||||
|
||||
@@ -15,8 +15,6 @@ 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)
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ Input modes:
|
||||
OpenClaw ships a default for `claude-cli`:
|
||||
|
||||
- `command: "claude"`
|
||||
- `args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"]`
|
||||
- `resumeArgs: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions", "--resume", "{sessionId}"]`
|
||||
- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]`
|
||||
- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]`
|
||||
- `modelArg: "--model"`
|
||||
- `systemPromptArg: "--append-system-prompt"`
|
||||
- `sessionArg: "--session-id"`
|
||||
|
||||
@@ -245,7 +245,6 @@ 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: {
|
||||
@@ -306,8 +305,7 @@ 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; 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).
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- `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)
|
||||
@@ -319,7 +317,6 @@ 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).
|
||||
@@ -801,21 +798,6 @@ 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 or SecretRef (env/file/exec providers)
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
@@ -630,56 +630,7 @@ 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) 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)
|
||||
### 0.4.1) 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:
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ OPENCLAW_LIVE_SETUP_TOKEN=1 OPENCLAW_LIVE_SETUP_TOKEN_PROFILE=anthropic:setup-to
|
||||
- Defaults:
|
||||
- Model: `claude-cli/claude-sonnet-4-6`
|
||||
- Command: `claude`
|
||||
- Args: `["-p","--output-format","json","--permission-mode","bypassPermissions"]`
|
||||
- Args: `["-p","--output-format","json","--dangerously-skip-permissions"]`
|
||||
- Overrides (optional):
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-opus-4-6"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.3-codex"`
|
||||
|
||||
@@ -28,9 +28,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
---
|
||||
summary: "Perplexity Search API setup for web_search"
|
||||
summary: "Perplexity Sonar setup for web_search"
|
||||
read_when:
|
||||
- You want to use Perplexity Search for web search
|
||||
- You need PERPLEXITY_API_KEY setup
|
||||
title: "Perplexity Search"
|
||||
- You want to use Perplexity Sonar for web search
|
||||
- You need PERPLEXITY_API_KEY or OpenRouter setup
|
||||
title: "Perplexity Sonar"
|
||||
---
|
||||
|
||||
# Perplexity Search API
|
||||
# Perplexity Sonar
|
||||
|
||||
OpenClaw uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set.
|
||||
Perplexity Search returns structured results (title, URL, snippet) for fast research.
|
||||
OpenClaw can use Perplexity Sonar for the `web_search` tool. You can connect
|
||||
through Perplexity’s direct API or via OpenRouter.
|
||||
|
||||
## Getting a Perplexity API key
|
||||
## API options
|
||||
|
||||
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
|
||||
2. Generate an API key in the dashboard
|
||||
3. Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment.
|
||||
### Perplexity (direct)
|
||||
|
||||
- Base URL: [https://api.perplexity.ai](https://api.perplexity.ai)
|
||||
- Environment variable: `PERPLEXITY_API_KEY`
|
||||
|
||||
### OpenRouter (alternative)
|
||||
|
||||
- Base URL: [https://openrouter.ai/api/v1](https://openrouter.ai/api/v1)
|
||||
- Environment variable: `OPENROUTER_API_KEY`
|
||||
- Supports prepaid/crypto credits.
|
||||
|
||||
## Config example
|
||||
|
||||
@@ -27,6 +34,8 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -44,6 +53,7 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,83 +61,20 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
}
|
||||
```
|
||||
|
||||
## Where to set the key (recommended)
|
||||
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
|
||||
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
|
||||
to disambiguate.
|
||||
|
||||
**Recommended:** run `openclaw configure --section web`. It stores the key in
|
||||
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
|
||||
If no base URL is set, OpenClaw chooses a default based on the API key source:
|
||||
|
||||
**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process
|
||||
environment. For a gateway install, put it in `~/.openclaw/.env` (or your
|
||||
service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
- `PERPLEXITY_API_KEY` or `pplx-...` → direct Perplexity (`https://api.perplexity.ai`)
|
||||
- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`)
|
||||
- Unknown key formats → OpenRouter (safe fallback)
|
||||
|
||||
## Tool parameters
|
||||
## Models
|
||||
|
||||
| Parameter | Description |
|
||||
| --------------------- | ---------------------------------------------------- |
|
||||
| `query` | Search query (required) |
|
||||
| `count` | Number of results to return (1-10, default: 5) |
|
||||
| `country` | 2-letter ISO country code (e.g., "US", "DE") |
|
||||
| `language` | ISO 639-1 language code (e.g., "en", "de", "fr") |
|
||||
| `freshness` | Time filter: `day` (24h), `week`, `month`, or `year` |
|
||||
| `date_after` | Only results published after this date (YYYY-MM-DD) |
|
||||
| `date_before` | Only results published before this date (YYYY-MM-DD) |
|
||||
| `domain_filter` | Domain allowlist/denylist array (max 20) |
|
||||
| `max_tokens` | Total content budget (default: 25000, max: 1000000) |
|
||||
| `max_tokens_per_page` | Per-page token limit (default: 2048) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
// Country and language-specific search
|
||||
await web_search({
|
||||
query: "renewable energy",
|
||||
country: "DE",
|
||||
language: "de",
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "AI news",
|
||||
freshness: "week",
|
||||
});
|
||||
|
||||
// Date range search
|
||||
await web_search({
|
||||
query: "AI developments",
|
||||
date_after: "2024-01-01",
|
||||
date_before: "2024-06-30",
|
||||
});
|
||||
|
||||
// Domain filtering (allowlist)
|
||||
await web_search({
|
||||
query: "climate research",
|
||||
domain_filter: ["nature.com", "science.org", ".edu"],
|
||||
});
|
||||
|
||||
// Domain filtering (denylist - prefix with -)
|
||||
await web_search({
|
||||
query: "product reviews",
|
||||
domain_filter: ["-reddit.com", "-pinterest.com"],
|
||||
});
|
||||
|
||||
// More content extraction
|
||||
await web_search({
|
||||
query: "detailed AI research",
|
||||
max_tokens: 50000,
|
||||
max_tokens_per_page: 4096,
|
||||
});
|
||||
```
|
||||
|
||||
### Domain filter rules
|
||||
|
||||
- Maximum 20 domains per filter
|
||||
- Cannot mix allowlist and denylist in the same request
|
||||
- Use `-` prefix for denylist entries (e.g., `["-reddit.com"]`)
|
||||
|
||||
## Notes
|
||||
|
||||
- Perplexity Search API returns structured web search results (title, URL, snippet)
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
|
||||
- `perplexity/sonar` — fast Q&A with web search
|
||||
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
|
||||
- `perplexity/sonar-reasoning-pro` — deep research
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
See [Perplexity Search API docs](https://docs.perplexity.ai/docs/search/quickstart) for more details.
|
||||
|
||||
@@ -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,8 +89,7 @@ Scope intent:
|
||||
|
||||
- `profiles.*.keyRef` (`type: "api_key"`)
|
||||
- `profiles.*.tokenRef` (`type: "token"`)
|
||||
|
||||
[//]: # "secretref-supported-list-end"
|
||||
<!-- secretref-supported-list-end -->
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -105,7 +104,7 @@ Notes:
|
||||
|
||||
Out-of-scope credentials include:
|
||||
|
||||
[//]: # "secretref-unsupported-list-start"
|
||||
<!-- secretref-unsupported-list-start -->
|
||||
|
||||
- `gateway.auth.token`
|
||||
- `commands.ownerDisplaySecret`
|
||||
@@ -117,8 +116,7 @@ Out-of-scope credentials include:
|
||||
- `auth-profiles.oauth.*`
|
||||
- `discord.threadBindings.*.webhookToken`
|
||||
- `whatsapp.creds.json`
|
||||
|
||||
[//]: # "secretref-unsupported-list-end"
|
||||
<!-- secretref-unsupported-list-end -->
|
||||
|
||||
Rationale:
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ This folder is home. Treat it that way.
|
||||
|
||||
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
|
||||
|
||||
## Session Startup
|
||||
## Every Session
|
||||
|
||||
Before doing anything else:
|
||||
|
||||
@@ -52,7 +52,7 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u
|
||||
- When you make a mistake → document it so future-you doesn't repeat it
|
||||
- **Text > Brain** 📝
|
||||
|
||||
## Red Lines
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
- Don't run destructive commands without asking.
|
||||
|
||||
@@ -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](/security/THREAT-MODEL-ATLAS)
|
||||
- [OpenClaw Threat Model](./THREAT-MODEL-ATLAS.md)
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## Documents
|
||||
|
||||
- [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
|
||||
- [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
|
||||
|
||||
## 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](/security/CONTRIBUTING-THREAT-MODEL) for guidelines on contributing:
|
||||
This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](./CONTRIBUTING-THREAT-MODEL.md) 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 or SecretRef (env/file/exec providers)
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
|
||||
@@ -119,8 +119,6 @@ Interface details:
|
||||
- `mode: "session"` requires `thread: true`
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
|
||||
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
|
||||
|
||||
## Sandbox compatibility
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?`
|
||||
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `attachments?`, `attachAs?`
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
@@ -483,7 +483,6 @@ Notes:
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
||||
- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
|
||||
- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery.
|
||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
|
||||
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
|
||||
@@ -497,7 +496,6 @@ Notes:
|
||||
- Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`).
|
||||
- `attachAs.mountPath` is a reserved hint for future mount implementations.
|
||||
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
|
||||
- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history.
|
||||
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
|
||||
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
|
||||
|
||||
@@ -30,14 +30,14 @@ Global defaults:
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false,
|
||||
historySize: 30,
|
||||
warningThreshold: 10,
|
||||
criticalThreshold: 20,
|
||||
globalCircuitBreakerThreshold: 30,
|
||||
historySize: 20,
|
||||
detectorCooldownMs: 12000,
|
||||
repeatThreshold: 3,
|
||||
criticalThreshold: 6,
|
||||
detectors: {
|
||||
genericRepeat: true,
|
||||
knownPollNoProgress: true,
|
||||
pingPong: true,
|
||||
repeatedFailure: true,
|
||||
knownPollLoop: true,
|
||||
repeatingNoProgress: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,8 +55,8 @@ Per-agent override (optional):
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
warningThreshold: 8,
|
||||
criticalThreshold: 16,
|
||||
repeatThreshold: 2,
|
||||
criticalThreshold: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -69,20 +69,18 @@ Per-agent override (optional):
|
||||
|
||||
- `enabled`: Master switch. `false` means no loop detection is performed.
|
||||
- `historySize`: number of recent tool calls kept for analysis.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
## Recommended setup
|
||||
|
||||
- Start with `enabled: true`, defaults unchanged.
|
||||
- Keep thresholds ordered as `warningThreshold < criticalThreshold < globalCircuitBreakerThreshold`.
|
||||
- If false positives occur:
|
||||
- raise `warningThreshold` and/or `criticalThreshold`
|
||||
- (optionally) raise `globalCircuitBreakerThreshold`
|
||||
- raise `repeatThreshold` and/or `criticalThreshold`
|
||||
- disable only the detector causing issues
|
||||
- reduce `historySize` for less strict historical context
|
||||
|
||||
|
||||
@@ -106,56 +106,6 @@ 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/compat` for bundled/internal plugin code that needs broader shared runtime helpers than `core`.
|
||||
- `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.
|
||||
- `openclaw/plugin-sdk/msteams` for the bundled Microsoft Teams plugin surface.
|
||||
- Bundled extension-specific subpaths are also available:
|
||||
`openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`,
|
||||
`openclaw/plugin-sdk/copilot-proxy`, `openclaw/plugin-sdk/device-pair`,
|
||||
`openclaw/plugin-sdk/diagnostics-otel`, `openclaw/plugin-sdk/diffs`,
|
||||
`openclaw/plugin-sdk/feishu`,
|
||||
`openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`,
|
||||
`openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`,
|
||||
`openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`,
|
||||
`openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`,
|
||||
`openclaw/plugin-sdk/memory-lancedb`,
|
||||
`openclaw/plugin-sdk/minimax-portal-auth`,
|
||||
`openclaw/plugin-sdk/nextcloud-talk`, `openclaw/plugin-sdk/nostr`,
|
||||
`openclaw/plugin-sdk/open-prose`, `openclaw/plugin-sdk/phone-control`,
|
||||
`openclaw/plugin-sdk/qwen-portal-auth`, `openclaw/plugin-sdk/synology-chat`,
|
||||
`openclaw/plugin-sdk/talk-voice`, `openclaw/plugin-sdk/test-utils`,
|
||||
`openclaw/plugin-sdk/thread-ownership`, `openclaw/plugin-sdk/tlon`,
|
||||
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
|
||||
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- `openclaw/plugin-sdk` remains supported for existing external plugins.
|
||||
- New and migrated bundled plugins should use channel or extension-specific
|
||||
subpaths; use `core` for generic surfaces and `compat` only when broader
|
||||
shared helpers are required.
|
||||
|
||||
Performance note:
|
||||
|
||||
- Plugin discovery and manifest metadata use short in-process caches to reduce
|
||||
bursty startup/reload work.
|
||||
- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or
|
||||
`OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches.
|
||||
- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and
|
||||
`OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`.
|
||||
|
||||
## Discovery & precedence
|
||||
|
||||
OpenClaw scans, in order:
|
||||
@@ -174,21 +124,13 @@ OpenClaw scans, in order:
|
||||
- `~/.openclaw/extensions/*.ts`
|
||||
- `~/.openclaw/extensions/*/index.ts`
|
||||
|
||||
4. Bundled extensions (shipped with OpenClaw, mostly disabled by default)
|
||||
4. Bundled extensions (shipped with OpenClaw, **disabled by default**)
|
||||
|
||||
- `<openclaw>/extensions/*`
|
||||
|
||||
Most bundled plugins must be enabled explicitly via
|
||||
`plugins.entries.<id>.enabled` or `openclaw plugins enable <id>`.
|
||||
|
||||
Default-on bundled plugin exceptions:
|
||||
|
||||
- `device-pair`
|
||||
- `phone-control`
|
||||
- `talk-voice`
|
||||
- active memory slot plugin (default slot: `memory-core`)
|
||||
|
||||
Installed plugins are enabled by default, but can be disabled the same way.
|
||||
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
||||
or `openclaw plugins enable <id>`. Installed plugins are enabled by default,
|
||||
but can be disabled the same way.
|
||||
|
||||
Hardening notes:
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Perplexity Search API, Brave, Gemini, Grok, and Kimi providers)"
|
||||
summary: "Web search + fetch tools (Brave, Perplexity, Gemini, Grok, and Kimi providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Perplexity or Brave Search API key setup
|
||||
- You need Brave Search API key setup
|
||||
- You want to use Perplexity Sonar for web search
|
||||
- You want to use Gemini with Google Search grounding
|
||||
title: "Web Tools"
|
||||
---
|
||||
@@ -11,7 +12,7 @@ title: "Web Tools"
|
||||
|
||||
OpenClaw ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Search the web using Perplexity Search API, Brave Search API, Gemini with Google Search grounding, Grok, or Kimi.
|
||||
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, Gemini with Google Search grounding, Grok, or Kimi.
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -20,22 +21,25 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
## How it works
|
||||
|
||||
- `web_search` calls your configured provider and returns results.
|
||||
- **Brave** (default): returns structured results (title, URL, snippet).
|
||||
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
|
||||
- **Gemini**: returns AI-synthesized answers grounded in Google Search with citations.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
See [Perplexity Search setup](/perplexity) and [Brave Search setup](/brave-search) for provider-specific details.
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------- |
|
||||
| **Perplexity Search API** | Fast, structured results; domain, language, region, and freshness filters; content extraction | — | `PERPLEXITY_API_KEY` |
|
||||
| **Brave Search API** | Fast, structured results | Fewer filtering options; AI-use terms apply | `BRAVE_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------------- | -------------------------------------------- |
|
||||
| **Brave** (default) | Fast, structured results | Traditional search results; AI-use terms apply | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||
|
||||
### Auto-detection
|
||||
|
||||
@@ -44,40 +48,81 @@ If no `provider` is explicitly set, OpenClaw auto-detects which provider to use
|
||||
1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
|
||||
2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
|
||||
3. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
|
||||
4. **Perplexity** — `PERPLEXITY_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
|
||||
4. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
|
||||
5. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
|
||||
## Setting up web search
|
||||
### Explicit provider
|
||||
|
||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||
Set the provider in config:
|
||||
|
||||
### Perplexity Search
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave", // or "perplexity" or "gemini" or "grok" or "kimi"
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
|
||||
2. Generate an API key in the dashboard
|
||||
3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment.
|
||||
Example: switch to Perplexity Sonar (direct API):
|
||||
|
||||
See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details.
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Brave Search
|
||||
## Getting a Brave API key
|
||||
|
||||
1. Create a Brave Search API account at <https://brave.com/search/api/>
|
||||
2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key.
|
||||
1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/)
|
||||
2. In the dashboard, choose the **Data for Search** plan (not “Data for AI”) and generate an API key.
|
||||
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides paid plans; check the Brave API portal for the current limits and pricing.
|
||||
Brave provides paid plans; check the Brave API portal for the
|
||||
current limits and pricing.
|
||||
|
||||
### Where to store the key
|
||||
Brave Terms include restrictions on some AI-related uses of Search Results.
|
||||
Review the Brave Terms of Service and confirm your intended use is compliant.
|
||||
For legal questions, consult your counsel.
|
||||
|
||||
**Via config (recommended):** run `openclaw configure --section web`. It stores the key under `tools.web.search.perplexity.apiKey` or `tools.web.search.apiKey`.
|
||||
### Where to set the key (recommended)
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
**Recommended:** run `openclaw configure --section web`. It stores the key in
|
||||
`~/.openclaw/openclaw.json` under `tools.web.search.apiKey`.
|
||||
|
||||
### Config examples
|
||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||
environment. For a gateway install, put it in `~/.openclaw/.env` (or your
|
||||
service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
**Perplexity Search:**
|
||||
## Using Perplexity (direct or via OpenRouter)
|
||||
|
||||
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
|
||||
answers with citations. You can use them via OpenRouter (no credit card required - supports
|
||||
crypto/prepaid).
|
||||
|
||||
### Getting an OpenRouter API key
|
||||
|
||||
1. Create an account at [https://openrouter.ai/](https://openrouter.ai/)
|
||||
2. Add credits (supports crypto, prepaid, or credit card)
|
||||
3. Generate an API key in your account settings
|
||||
|
||||
### Setting up Perplexity search
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -87,7 +132,12 @@ Brave provides paid plans; check the Brave API portal for the current limits and
|
||||
enabled: true,
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...", // optional if PERPLEXITY_API_KEY is set
|
||||
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
|
||||
apiKey: "sk-or-v1-...",
|
||||
// Base URL (key-aware default if omitted)
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
// Model (defaults to perplexity/sonar-pro)
|
||||
model: "perplexity/sonar-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,21 +145,22 @@ Brave provides paid plans; check the Brave API portal for the current limits and
|
||||
}
|
||||
```
|
||||
|
||||
**Brave Search:**
|
||||
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
|
||||
environment. For a gateway install, put it in `~/.openclaw/.env`.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "BSA...", // optional if BRAVE_API_KEY is set
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
If no base URL is set, OpenClaw chooses a default based on the API key source:
|
||||
|
||||
- `PERPLEXITY_API_KEY` or `pplx-...` → `https://api.perplexity.ai`
|
||||
- `OPENROUTER_API_KEY` or `sk-or-...` → `https://openrouter.ai/api/v1`
|
||||
- Unknown key formats → OpenRouter (safe fallback)
|
||||
|
||||
### Available Perplexity models
|
||||
|
||||
| Model | Description | Best for |
|
||||
| -------------------------------- | ------------------------------------ | ----------------- |
|
||||
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
|
||||
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
||||
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
||||
|
||||
## Using Gemini (Google Search grounding)
|
||||
|
||||
@@ -163,7 +214,7 @@ Search the web using your configured provider.
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey`
|
||||
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
|
||||
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
@@ -188,21 +239,14 @@ Search the web using your configured provider.
|
||||
|
||||
### Tool parameters
|
||||
|
||||
All parameters work for both Brave and Perplexity unless noted.
|
||||
|
||||
| Parameter | Description |
|
||||
| --------------------- | ----------------------------------------------------- |
|
||||
| `query` | Search query (required) |
|
||||
| `count` | Results to return (1-10, default: 5) |
|
||||
| `country` | 2-letter ISO country code (e.g., "US", "DE") |
|
||||
| `language` | ISO 639-1 language code (e.g., "en", "de") |
|
||||
| `freshness` | Time filter: `day`, `week`, `month`, or `year` |
|
||||
| `date_after` | Results after this date (YYYY-MM-DD) |
|
||||
| `date_before` | Results before this date (YYYY-MM-DD) |
|
||||
| `ui_lang` | UI language code (Brave only) |
|
||||
| `domain_filter` | Domain allowlist/denylist array (Perplexity only) |
|
||||
| `max_tokens` | Total content budget, default 25000 (Perplexity only) |
|
||||
| `max_tokens_per_page` | Per-page token limit, default 2048 (Perplexity only) |
|
||||
- `query` (required)
|
||||
- `count` (1–10; default from config)
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
- `freshness` (optional): filter by discovery time
|
||||
- Brave: `pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`
|
||||
- Perplexity: `pd`, `pw`, `pm`, `py`
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -210,40 +254,23 @@ All parameters work for both Brave and Perplexity unless noted.
|
||||
// German-specific search
|
||||
await web_search({
|
||||
query: "TV online schauen",
|
||||
count: 10,
|
||||
country: "DE",
|
||||
language: "de",
|
||||
search_lang: "de",
|
||||
});
|
||||
|
||||
// French search with French UI
|
||||
await web_search({
|
||||
query: "actualités",
|
||||
country: "FR",
|
||||
search_lang: "fr",
|
||||
ui_lang: "fr",
|
||||
});
|
||||
|
||||
// Recent results (past week)
|
||||
await web_search({
|
||||
query: "TMBG interview",
|
||||
freshness: "week",
|
||||
});
|
||||
|
||||
// Date range search
|
||||
await web_search({
|
||||
query: "AI developments",
|
||||
date_after: "2024-01-01",
|
||||
date_before: "2024-06-30",
|
||||
});
|
||||
|
||||
// Domain filtering (Perplexity only)
|
||||
await web_search({
|
||||
query: "climate research",
|
||||
domain_filter: ["nature.com", "science.org", ".edu"],
|
||||
});
|
||||
|
||||
// Exclude domains (Perplexity only)
|
||||
await web_search({
|
||||
query: "product reviews",
|
||||
domain_filter: ["-reddit.com", "-pinterest.com"],
|
||||
});
|
||||
|
||||
// More content extraction (Perplexity only)
|
||||
await web_search({
|
||||
query: "detailed AI research",
|
||||
max_tokens: 50000,
|
||||
max_tokens_per_page: 4096,
|
||||
freshness: "pw",
|
||||
});
|
||||
```
|
||||
|
||||
@@ -304,4 +331,4 @@ Notes:
|
||||
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
|
||||
- Responses are cached (default 15 minutes) to reduce repeated fetches.
|
||||
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
|
||||
- If the API key is missing, `web_search` returns a short setup hint with a docs link.
|
||||
- If the Brave key is missing, `web_search` returns a short setup hint with a docs link.
|
||||
|
||||
@@ -19,7 +19,7 @@ x-i18n:
|
||||
|
||||
如果 `BOOTSTRAP.md` 存在,那就是你的"出生证明"。按照它的指引,弄清楚你是谁,然后删除它。你不会再需要它了。
|
||||
|
||||
## 会话启动
|
||||
## 每次会话
|
||||
|
||||
在做任何事情之前:
|
||||
|
||||
@@ -58,7 +58,7 @@ x-i18n:
|
||||
- 当你犯了错误 → 记录下来,这样未来的你不会重蹈覆辙
|
||||
- **文件 > 大脑** 📝
|
||||
|
||||
## 红线
|
||||
## 安全
|
||||
|
||||
- 不要泄露隐私数据。绝对不要。
|
||||
- 不要在未询问的情况下执行破坏性命令。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/acpx";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config.js";
|
||||
import { createAcpxRuntimeService } from "./src/service.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
|
||||
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk/acpx";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk";
|
||||
import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js";
|
||||
import {
|
||||
resolveSpawnFailure,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acpx";
|
||||
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js";
|
||||
import {
|
||||
resolveSpawnCommand,
|
||||
spawnAndCollect,
|
||||
type SpawnCommandCache,
|
||||
waitForExit,
|
||||
} from "./process.js";
|
||||
import { resolveSpawnCommand, type SpawnCommandCache } from "./process.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -231,62 +225,3 @@ describe("resolveSpawnCommand", () => {
|
||||
expect(second.args[0]).toBe(scriptPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForExit", () => {
|
||||
it("resolves when the child already exited before waiting starts", async () => {
|
||||
const child = spawn(process.execPath, ["-e", "process.exit(0)"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("close", () => {
|
||||
resolve();
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
expect(exit.code).toBe(0);
|
||||
expect(exit.signal).toBeNull();
|
||||
expect(exit.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnAndCollect", () => {
|
||||
it("returns abort error immediately when signal is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result.code).toBeNull();
|
||||
expect(result.error?.name).toBe("AbortError");
|
||||
});
|
||||
|
||||
it("terminates a running process when signal aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const resultPromise = spawnAndCollect(
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => process.stdout.write('done'), 10_000)"],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.error?.name).toBe("AbortError");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ import type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
export type SpawnExit = {
|
||||
code: number | null;
|
||||
@@ -114,12 +114,6 @@ export function resolveSpawnCommand(
|
||||
};
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted.");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function spawnWithResolvedCommand(
|
||||
params: {
|
||||
command: string;
|
||||
@@ -146,15 +140,6 @@ export function spawnWithResolvedCommand(
|
||||
}
|
||||
|
||||
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
|
||||
// Handle callers that start waiting after the child has already exited.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return {
|
||||
code: child.exitCode,
|
||||
signal: child.signalCode,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return await new Promise<SpawnExit>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: SpawnExit) => {
|
||||
@@ -182,23 +167,12 @@ export async function spawnAndCollect(
|
||||
cwd: string;
|
||||
},
|
||||
options?: SpawnCommandOptions,
|
||||
runtime?: {
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
if (runtime?.signal?.aborted) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: null,
|
||||
error: createAbortError(),
|
||||
};
|
||||
}
|
||||
const child = spawnWithResolvedCommand(params, options);
|
||||
child.stdin.end();
|
||||
|
||||
@@ -211,43 +185,13 @@ export async function spawnAndCollect(
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
let abortKillTimer: NodeJS.Timeout | undefined;
|
||||
let aborted = false;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Ignore kill races when child already exited.
|
||||
}
|
||||
abortKillTimer = setTimeout(() => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// Ignore kill races when child already exited.
|
||||
}
|
||||
}, 250);
|
||||
abortKillTimer.unref?.();
|
||||
const exit = await waitForExit(child);
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: exit.code,
|
||||
error: exit.error,
|
||||
};
|
||||
runtime?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const exit = await waitForExit(child);
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: exit.code,
|
||||
error: aborted ? createAbortError() : exit.error,
|
||||
};
|
||||
} finally {
|
||||
runtime?.signal?.removeEventListener("abort", onAbort);
|
||||
if (abortKillTimer) {
|
||||
clearTimeout(abortKillTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSpawnFailure(
|
||||
|
||||
@@ -75,35 +75,14 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
||||
if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") {
|
||||
emitJson({ action: "session_ensured", name: ensureName });
|
||||
} else {
|
||||
emitJson({
|
||||
action: "session_ensured",
|
||||
acpxRecordId: "rec-" + ensureName,
|
||||
acpxSessionId: "sid-" + ensureName,
|
||||
agentSessionId: "inner-" + ensureName,
|
||||
name: ensureName,
|
||||
created: true,
|
||||
});
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "new") {
|
||||
writeLog({ kind: "new", agent, args, sessionName: ensureName });
|
||||
if (process.env.MOCK_ACPX_NEW_EMPTY === "1") {
|
||||
emitJson({ action: "session_created", name: ensureName });
|
||||
} else {
|
||||
emitJson({
|
||||
action: "session_created",
|
||||
acpxRecordId: "rec-" + ensureName,
|
||||
acpxSessionId: "sid-" + ensureName,
|
||||
agentSessionId: "inner-" + ensureName,
|
||||
name: ensureName,
|
||||
created: true,
|
||||
});
|
||||
}
|
||||
emitJson({
|
||||
action: "session_ensured",
|
||||
acpxRecordId: "rec-" + ensureName,
|
||||
acpxSessionId: "sid-" + ensureName,
|
||||
agentSessionId: "inner-" + ensureName,
|
||||
name: ensureName,
|
||||
created: true,
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -377,51 +377,4 @@ describe("AcpxRuntime", () => {
|
||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
expect(report.installCommand).toContain("acpx");
|
||||
});
|
||||
|
||||
it("falls back to 'sessions new' when 'sessions ensure' returns no session IDs", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EMPTY = "1";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:claude:acp:fallback-test",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
});
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-agent:claude:acp:fallback-test");
|
||||
expect(handle.agentSessionId).toBe("inner-agent:claude:acp:fallback-test");
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "new")).toBe(true);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EMPTY;
|
||||
}
|
||||
});
|
||||
|
||||
it("fails with ACP_SESSION_INIT_FAILED when both ensure and new omit session IDs", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EMPTY = "1";
|
||||
process.env.MOCK_ACPX_NEW_EMPTY = "1";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:claude:acp:fallback-fail",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_SESSION_INIT_FAILED",
|
||||
message: expect.stringContaining("neither 'sessions ensure' nor 'sessions new'"),
|
||||
});
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "new")).toBe(true);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EMPTY;
|
||||
delete process.env.MOCK_ACPX_NEW_EMPTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { AcpRuntimeError } from "openclaw/plugin-sdk";
|
||||
import { type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { checkAcpxVersion } from "./ensure.js";
|
||||
import {
|
||||
@@ -179,7 +179,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
|
||||
let events = await this.runControlCommand({
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd,
|
||||
command: [agent, "sessions", "ensure", "--name", sessionName],
|
||||
@@ -187,36 +187,12 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
let ensuredEvent = events.find(
|
||||
const ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
|
||||
if (!ensuredEvent) {
|
||||
events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd,
|
||||
command: [agent, "sessions", "new", "--name", sessionName],
|
||||
}),
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
if (!ensuredEvent) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
|
||||
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
|
||||
const backendSessionId = ensuredEvent
|
||||
@@ -353,10 +329,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
return ACPX_CAPABILITIES;
|
||||
}
|
||||
|
||||
async getStatus(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AcpRuntimeStatus> {
|
||||
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
@@ -366,7 +339,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
signal: input.signal,
|
||||
});
|
||||
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
|
||||
if (!detail) {
|
||||
@@ -590,7 +562,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
cwd: string;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
ignoreNoSession?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AcpxJsonObject[]> {
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
@@ -599,9 +570,6 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
cwd: params.cwd,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
{
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/acpx";
|
||||
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
|
||||
import {
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk/acpx";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
|
||||
import { resolveAcpxPluginConfig, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { ensureAcpx } from "./ensure.js";
|
||||
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
@@ -17,7 +13,7 @@ import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
type ResolvedBlueBubblesAccount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createScopedPairingAccess,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
resolveControlCommandGate,
|
||||
stripMarkdown,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import type { BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
readWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
resolveWebhookTargets,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/bluebubbles", () => ({
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
addWildcardAllowFrom: vi.fn(),
|
||||
formatDocsLink: (_url: string, fallback: string) => fallback,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
OpenClawConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
addWildcardAllowFrom,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
resolveBlueBubblesAccount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
getCachedBlueBubblesPrivateApiStatus,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ParsedChatTarget,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
||||
|
||||
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
|
||||
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
||||
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/copilot-proxy";
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user