Compare commits

...

326 Commits

Author SHA1 Message Date
Peter Steinberger
929666a8c8 fix: add telegram custom commands (#860) (thanks @nachoiacovino)
Co-authored-by: Nacho Iacovino <50103937+nachoiacovino@users.noreply.github.com>
2026-01-16 08:22:09 +00:00
Muhammed Mukhthar CM
cd409e5667 fix: exclude google-antigravity from history downgrade hack (#894)
* Agent: exclude google-antigravity from history downgrade hack

* Lint: fix formatting in test

* Lint: formatting and unused vars in test

* fix: preserve google-antigravity tool calls (#894) (thanks @mukhtharcm)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 08:14:56 +00:00
Peter Steinberger
d0cb4e092f Merge pull request #845 from MatthieuBizien/fix/issue-841-openrouter-gemini
Agents: sanitize OpenRouter Gemini thoughtSignature
2026-01-16 08:03:40 +00:00
Peter Steinberger
f5a881c99d fix: port OpenRouter Gemini sanitization to split files (#845) (thanks @MatthieuBizien) 2026-01-16 08:02:56 +00:00
Peter Steinberger
66377fc030 fix: update macOS IPC tests 2026-01-16 07:58:35 +00:00
Matthieu Bizien
d8d295b0b3 Changelog: thank PR #845 2026-01-16 07:51:49 +00:00
Matthieu Bizien
ef36e24522 Agents: sanitize OpenRouter Gemini thoughtSignature 2026-01-16 07:51:49 +00:00
Palash Oswal
d43d4fcced Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs (#885)
* Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs

* fix: allow local Tailscale Serve hostnames (#885) (thanks @oswalpalash)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 07:51:25 +00:00
Peter Steinberger
d42b69df74 Merge pull request #982 from wes-davis/fix/gateway-connection-diagnostics
macOS: keep gateway connected (stop port flapping)
2026-01-16 07:36:46 +00:00
Peter Steinberger
1ec1f6dcbf fix: sync remote ssh targets 2026-01-16 07:33:15 +00:00
Peter Steinberger
e96b939732 feat: add system.which bin probe 2026-01-16 07:31:41 +00:00
Peter Steinberger
e479c870fd fix: handle MiniMax coding plan usage payloads 2026-01-16 07:28:48 +00:00
Peter Steinberger
f2db894685 Merge pull request #992 from tyler6204/fix/tool-typing-race-condition
fix: send text between tool calls to channel immediately
2026-01-16 07:26:10 +00:00
Peter Steinberger
4f03283126 docs: add npm 1password publish steps 2026-01-16 07:08:07 +00:00
Peter Steinberger
ed7dec0975 feat: use dropdowns for access controls 2026-01-16 07:00:05 +00:00
Peter Steinberger
fbb3da506f docs: clarify onboarding + sessions + heartbeats 2026-01-16 06:57:54 +00:00
Peter Steinberger
dfa6c5c2b3 fix(google): scrub tool schemas for gemini 2026-01-16 06:57:54 +00:00
Peter Steinberger
028eed5fe8 fix(browser): surface detection details and docs 2026-01-16 06:57:54 +00:00
Peter Steinberger
2b16a87f04 feat: add config get/set/unset helpers 2026-01-16 06:57:54 +00:00
Peter Steinberger
731049936d Merge pull request #870 from JDIVE/fix/discord-actions-schema
fix(config): allow discord action flags in schema
2026-01-16 06:50:50 +00:00
Peter Steinberger
5a5b058ba0 fix: update discord action defaults (#870) (thanks @JDIVE) 2026-01-16 06:50:27 +00:00
Jamie Openshaw
72f28be648 fix(config): allow discord action flags in schema
Ensure discord action flags survive config validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 06:48:25 +00:00
Peter Steinberger
ff645524d8 docs: move troubleshooting items from faq 2026-01-16 06:35:13 +00:00
Peter Steinberger
c534390bc0 chore: update a2ui bundle 2026-01-16 06:35:05 +00:00
Peter Steinberger
c5003e5441 fix: clear lint blockers 2026-01-16 06:35:05 +00:00
Peter Steinberger
0b3ebb0c63 test: stabilize slow and flaky tests 2026-01-16 06:24:58 +00:00
Peter Steinberger
23981496f9 fix: resolve bridge warnings 2026-01-16 06:15:45 +00:00
Peter Steinberger
f2e425dc2b Merge pull request #995 from roshanasingh4/fix/systemd-execstart-whitespace
Fix systemd ExecStart whitespace parsing
2026-01-16 06:03:35 +00:00
Peter Steinberger
e48d68bbc7 Merge pull request #993 from cpojer/reminder-improvement
Improve reminder text generation.
2026-01-16 06:03:04 +00:00
Peter Steinberger
842fc8d08b fix: repair launchd status parsing 2026-01-16 06:01:28 +00:00
Peter Steinberger
54ec14262b feat: add plugin update tracking 2026-01-16 05:55:05 +00:00
Peter Steinberger
d0c70178e0 docs: fix FAQ reset anchor 2026-01-16 05:54:56 +00:00
Peter Steinberger
3bc9c330eb fix: unblock mac node bridge TLS 2026-01-16 05:50:55 +00:00
Peter Steinberger
b7fcc8584f style: apply swiftformat fixes 2026-01-16 05:42:18 +00:00
Peter Steinberger
2b8ce3f06b feat: add json output for daemon lifecycle 2026-01-16 05:40:47 +00:00
Peter Steinberger
41d44021e7 feat(browser): prefer default chromium browser 2026-01-16 05:37:53 +00:00
Peter Steinberger
1ab1e312b2 feat: add TLS for node bridge 2026-01-16 05:28:40 +00:00
Roshan Singh
fa9aafce83 Fix systemd ExecStart parsing whitespace 2026-01-16 05:25:13 +00:00
Tyler Yust
0d5dec4c66 fix: handle async tool start handler rejections
Add .catch() to handleToolExecutionStart call to prevent unhandled
promise rejections when onAgentEvent or typing signaling fails.
2026-01-15 21:10:52 -08:00
cpojer
b2d5889f6e Improve reminder text generation. 2026-01-16 14:03:17 +09:00
Tyler Yust
2ee71e4154 fix: send text between tool calls to channel immediately
Previously, when block streaming was disabled (the default), text generated
between tool calls would only appear after all tools completed. This was
because onBlockReply wasn't passed to the subscription when block streaming
was off, so flushBlockReplyBuffer() before tool execution did nothing.

Now onBlockReply is always passed, and when block streaming is disabled,
block replies are sent directly during tool flush. Directly sent payloads
are tracked to avoid duplicates in final payloads.

Also fixes a race condition where tool summaries could be emitted before
the typing indicator started by awaiting onAgentEvent in tool handlers.
2026-01-15 20:55:52 -08:00
Peter Steinberger
1656f491fd fix: normalize pairing aliases and webhook guard (#991) (thanks @longmaba) 2026-01-16 04:55:16 +00:00
Peter Steinberger
a057b3c9e8 Merge pull request #991 from longmaba/fix/zalo-pairing-and-webhook
fix(zalo): fix pairing channel detection and webhook payload format
2026-01-16 04:54:08 +00:00
Peter Steinberger
236b27cb3a docs: align plugin changelogs 2026-01-16 04:15:48 +00:00
Peter Steinberger
1634abf293 docs: expand msteams plugin changelog 2026-01-16 04:15:48 +00:00
Ubuntu
ca9688b5cc feat(session): add dmScope for multi-user DM isolation
Co-authored-by: Alphonse-arianee <Alphonse-arianee@users.noreply.github.com>
2026-01-16 04:13:10 +00:00
Peter Steinberger
e6364d031d fix: stabilize windows queue + pairing tests 2026-01-16 04:02:43 +00:00
Peter Steinberger
01c8d099ad fix: repair CI formatting + launchd test 2026-01-16 03:52:47 +00:00
Peter Steinberger
f6e619f078 fix: normalize daemon unit paths 2026-01-16 03:47:26 +00:00
Peter Steinberger
b2b331230b feat: mac node exec policy + remote skills hot reload 2026-01-16 03:45:06 +00:00
Long
c0c9742e44 fix(zalo): fix pairing channel detection and webhook payload format
Amp-Thread-ID: https://ampcode.com/threads/T-019bc4e0-fcb1-77be-b0b5-0d498f0c7197
Co-authored-by: Amp <amp@ampcode.com>
2026-01-16 10:43:14 +07:00
Peter Steinberger
abcca86e4e chore: format and sync protocol outputs 2026-01-16 03:30:56 +00:00
Peter Steinberger
a5d8f89b53 feat(browser): prefer Chrome default + add Brave/Edge fallbacks
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-16 03:28:56 +00:00
Peter Steinberger
a0d2a7232e fix: allow media-only sends 2026-01-16 03:15:26 +00:00
Peter Steinberger
f449115ec5 test: extend vitest timeouts 2026-01-16 03:11:16 +00:00
Peter Steinberger
16bc4cdef3 chore: drop legacy Relay signing 2026-01-16 03:11:16 +00:00
Peter Steinberger
23e4ba845c fix: sanitize user-facing errors and strip final tags
Co-authored-by: Drake Thomsen <drake.thomsen@example.com>
2026-01-16 03:01:23 +00:00
Peter Steinberger
d9f9e93dee feat!: move msteams to plugin 2026-01-16 02:59:43 +00:00
Peter Steinberger
dae34f3a61 docs: clarify setup-token location 2026-01-16 02:53:40 +00:00
Adam Holt
af61b353a4 docs(showcase): update Dream Team card with architecture articles
Combine Multi-Agent Swarm card with new technical write-ups:
- Link to orchestrated-ai-articles repo (manifesto + architecture)
- Keep clawdspace link for agent sandboxing
- Add blog post link

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 02:52:27 +00:00
Peter Steinberger
29476b222d fix: restore status usage summary output 2026-01-16 02:49:18 +00:00
Peter Steinberger
e7c16cc0e6 docs: clarify setup-token flows 2026-01-16 02:36:32 +00:00
Peter Steinberger
3dddbe1053 fix: ignore properties in google tool schema warnings 2026-01-16 02:35:55 +00:00
Peter Steinberger
2dea6bfa7e docs: clarify gog body-file usage
Co-authored-by: John Young <johntheyoung@users.noreply.github.com>
2026-01-16 02:23:41 +00:00
Peter Steinberger
04e3bfed35 docs: clarify Anthropic setup-token 2026-01-16 02:19:57 +00:00
Peter Steinberger
3e32050601 fix: correct final tag strip typing 2026-01-16 02:16:17 +00:00
Peter Steinberger
7fb45ed9b8 fix: strip final tags from session messages 2026-01-16 02:16:17 +00:00
Peter Steinberger
b7ba94f0c1 fix: harden antigravity claude support (#968)
Co-authored-by: Max <rdev@users.noreply.github.com>
2026-01-16 02:16:17 +00:00
Peter Steinberger
5b827528f8 fix: show oauth usage in /status 2026-01-16 02:06:27 +00:00
Peter Steinberger
409e33d9c2 Merge pull request #960 from kkarimi/fix/mac-node-bridge-tunnel-865
macOS: prefer bridge tunnel port in remote mode
2026-01-16 02:00:23 +00:00
Peter Steinberger
747277d914 fix: document macOS remote tunnels (#960) (thanks @kkarimi) 2026-01-16 01:59:14 +00:00
Nima Karimi
068dca3366 Tests: apply suite timeout in node bridge test 2026-01-16 01:56:23 +00:00
Nima Karimi
336a1ad9cf Tests: extend node bridge server timeout 2026-01-16 01:56:23 +00:00
Nima Karimi
d42f767d0c SwiftFormat: format macOS sources 2026-01-16 01:56:23 +00:00
Nima Karimi
9b8ae62399 macOS: prefer bridge tunnel port in remote mode 2026-01-16 01:56:06 +00:00
Gustavo Madeira Santana
7c38b535f6 Document inline buttons configuration for Telegram (#984) 2026-01-15 19:55:03 -06:00
Peter Steinberger
af370ab23e fix: align config types after upstream changes 2026-01-16 01:49:07 +00:00
Peter Steinberger
1210657fda chore: bump Peekaboo submodule 2026-01-16 01:44:12 +00:00
Peter Steinberger
12afec953f test: stabilize sandbox config tests 2026-01-16 01:44:12 +00:00
Peter Steinberger
8e2707e232 fix: cleanup suspended CLI processes (#978) (thanks @Nachx639) 2026-01-16 01:39:33 +00:00
Tu Nombre Real
8befe7f8a7 fix: cleanup suspended Clawdbot CLI processes
Add cleanupSuspendedCliProcesses() to kill accumulated suspended processes
from isolated sessions that don't share sessionIds (e.g., cron jobs).

- Only targets Clawdbot processes (--session-id pattern)
- Only kills suspended processes (state T)
- Only triggers when >10 processes accumulated
- Does not affect user's Claude Code sessions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:38:03 +00:00
Peter Steinberger
a70fcc8ae0 chore(cli): set process title 2026-01-16 01:33:28 +00:00
Peter Steinberger
fa521154ff fix: show disabled channels in onboarding picker 2026-01-16 01:29:25 +00:00
Peter Steinberger
dffc1a4dcd fix: switch channel onboarding to single-select loop 2026-01-16 01:21:17 +00:00
Peter Steinberger
61e385b331 feat: add per-agent heartbeat config 2026-01-16 01:17:34 +00:00
Peter Steinberger
f8f319713f fix: show provider/model labels in TUI 2026-01-16 01:13:14 +00:00
Wes
509215e935 macOS: stop flapping gateway port 2026-01-15 17:12:14 -08:00
Wes
f726656d1e macOS: start gateway before app in restart-mac 2026-01-15 17:12:14 -08:00
Wes
30d3e1da21 CLI: fix status --all gateway auth selection 2026-01-15 17:12:14 -08:00
Peter Steinberger
bb665bf22c fix: persist local gateway mode in configure wizard 2026-01-16 01:05:55 +00:00
Peter Steinberger
f17dcb6213 fix: refine channel onboarding selection flow 2026-01-16 01:05:50 +00:00
Peter Steinberger
d54c101100 docs: update release checklist 2026-01-16 00:42:48 +00:00
Peter Steinberger
96daa51d45 docs: document provider auth plugins 2026-01-16 00:42:48 +00:00
Peter Steinberger
6084421ec6 feat: add provider auth plugins 2026-01-16 00:42:28 +00:00
Peter Steinberger
bca5c0d569 refactor: system prompt sections + docs/tests 2026-01-16 00:28:43 +00:00
Peter Steinberger
8c3cdba21c feat: sticky auth profile rotation + usage headers 2026-01-16 00:25:49 +00:00
void
e274b5a040 fix: heartbeat prompt + dedupe (#980) (thanks @voidserf)
- tighten default heartbeat prompt guidance
- suppress duplicate heartbeat alerts within 24h

Co-authored-by: void <voidserf@users.noreply.github.com>
2026-01-16 00:24:52 +00:00
Josh Lehman
a139d35fa2 feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes

User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.

Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-16 00:11:33 +00:00
Peter Steinberger
8312a19f02 fix: handle Telegram General topic thread params (#848) (thanks @azade-c) 2026-01-16 00:08:56 +00:00
Peter Steinberger
fe8b28cdd9 Merge pull request #848 from azade-c/fix/telegram-general-topic-messages
fix(telegram): skip message_thread_id for General topic messages
2026-01-16 00:08:07 +00:00
Peter Steinberger
10eb1beccf fix: tighten session entry updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:32 +00:00
Peter Steinberger
a4b347b454 feat: refine subagents + add chat.inject
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:44:31 +00:00
Peter Steinberger
688a0ce439 refactor: harden session store updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-15 23:41:34 +00:00
Azade
6146acbb69 fix(telegram): separate thread params for typing vs messages
Telegram General topic (id=1) has inconsistent API behavior:
- sendMessage: rejects explicit message_thread_id=1
- sendChatAction: requires message_thread_id=1 for typing to show

Split into two helper functions:
- buildTelegramThreadParams: excludes General topic for messages
- buildTypingThreadParams: includes General topic for typing
2026-01-15 23:32:30 +00:00
Peter Steinberger
35492f8513 docs: add inbound debounce config example 2026-01-15 23:20:14 +00:00
Peter Steinberger
db9be87d94 refactor: centralize daemon path resolution 2026-01-15 23:19:52 +00:00
juanpablodlc
4a99b9b651 feat(whatsapp): add debounceMs for batching rapid messages (#971)
* feat(whatsapp): add debounceMs for batching rapid messages

Add a `debounceMs` configuration option to WhatsApp channel settings
that batches rapid consecutive messages from the same sender into a
single response. This prevents triggering separate agent runs for
each message when a user sends multiple short messages in quick
succession (e.g., "Hey!", "how are you?", "I was wondering...").

Changes:
- Add `debounceMs` config to WhatsAppConfig and WhatsAppAccountConfig
- Implement message buffering in `monitorWebInbox` with:
  - Map-based buffer keyed by sender (DM) or chat ID (groups)
  - Debounce timer that resets on each new message
  - Message combination with newline separator
  - Single message optimization (no modification if only one message)
- Wire `debounceMs` through account resolution and monitor tuning
- Add UI hints and schema documentation

Usage example:
{
  "channels": {
    "whatsapp": {
      "debounceMs": 5000  // 5 second window
    }
  }
}

Default behavior: `debounceMs: 0` (disabled by default)

Verified: All existing tests pass (3204 tests), TypeScript compilation
succeeds with no errors.

Implemented with assistance from AI coding tools.

Closes #967

* chore: wip inbound debounce

* fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 23:07:19 +00:00
Peter Steinberger
7dea403302 chore: purge DS_Store files 2026-01-15 22:59:16 +00:00
Peter Steinberger
10731dfee3 docs: add sync up shorthand 2026-01-15 22:58:34 +00:00
Peter Steinberger
d00f2d9c0c test: cover model picker ordering and openrouter index 2026-01-15 22:56:11 +00:00
Peter Steinberger
8b89980a89 feat(date-time): standardize time context and tool timestamps 2026-01-15 22:27:06 +00:00
Jake
634a429c50 fix(model-picker): list each provider/model combo separately (#970)
* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

Co-authored-by: Keith the Silly Goose <keith@42bolton.macnet.nz>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 22:20:11 +00:00
Peter Steinberger
bf90815b9e Merge pull request #969 from bjesuiter/fix/launchd-label-resolution
fix: unify daemon service label resolution with env parameter
2026-01-15 22:12:12 +00:00
Peter Steinberger
2b113c4d6c chore: update changelog (#969) (thanks @bjesuiter) 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter
7f6a288bd3 docs: clarify multi-gateway rescue bot guidance 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter
daf471c450 fix: unify daemon service label resolution with env 2026-01-15 22:10:27 +00:00
Peter Steinberger
cb78fa46a1 fix: make node-llama-cpp optional 2026-01-15 18:37:02 +00:00
Shadow
316e8b2eb2 Discord: fix allowlist gating. Closes #961 2026-01-15 11:53:13 -06:00
Peter Steinberger
4de81ed6c4 docs(changelog): move browser relay fixes to 2026.1.15 2026-01-15 17:26:54 +00:00
Peter Steinberger
b6fb24f6d2 Merge pull request #964 from bohdanpodvirnyi/feat/telegram-reactions
feat(telegram): add bidirectional emoji reactions support
2026-01-15 17:21:51 +00:00
Peter Steinberger
2b1c26f900 fix: refine telegram reactions (#964) (thanks @bohdanpodvirnyi) 2026-01-15 17:20:17 +00:00
Peter Steinberger
47ea9356d1 Merge pull request #959 from rdev/rdev/finally-fix-antigravity-claude
Fix antigravity claude
2026-01-15 17:10:04 +00:00
Bohdan Podvirnyi
f12c1b391f fix: lint errors 2026-01-15 17:08:31 +00:00
Peter Steinberger
05658b6609 fix: tighten command arg value types 2026-01-15 17:08:09 +00:00
Bohdan Podvirnyi
dfb6630de1 fix: remove dup definitions + add reaction config 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi
eb7656d68c fix: lint errors 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi
0e1dcf9cb4 feat: added capability for clawdbot to react 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi
d05c3d0659 feat: make telegram reactions visible to clawdbot 2026-01-15 17:07:38 +00:00
Peter Steinberger
36292d3fbb fix: preserve Antigravity Claude signatures (#959) (thanks @rdev) 2026-01-15 17:06:39 +00:00
Max
1ae344d8a6 Fix antigravity claude 2026-01-15 17:06:39 +00:00
Peter Steinberger
01c43b0b0c fix: avoid base string coercion in auto-reply formatting 2026-01-15 17:03:34 +00:00
Peter Steinberger
fc4aa9a683 Merge pull request #954 from roshanasingh4/fix/946-openai-completions-trim
Fix model fallback crash on undefined provider/model (#946)
2026-01-15 16:59:19 +00:00
Peter Steinberger
c043e9767f fix: default model fallback when provider/model missing (#954) (thanks @roshanasingh4) 2026-01-15 16:58:41 +00:00
Peter Steinberger
8b48299d8f Merge pull request #953 from roshanasingh4/fix/cli-quick-reference-system-prompt
Fix system prompt: prevent invented CLI commands
2026-01-15 16:55:52 +00:00
Roshan Singh
0f27cff247 Fix model fallback crash on undefined provider/model (#946) 2026-01-15 16:50:46 +00:00
Peter Steinberger
2d1ae0916f chore: start 2026.1.15 (unreleased) 2026-01-15 16:47:19 +00:00
Roshan Singh
d0455f2683 fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 11:43:22 +00:00
Peter Steinberger
f25fe032c4 fix(macos): codesign dmg before notarize 2026-01-15 11:21:25 +00:00
Peter Steinberger
0a5b1fbcd1 fix(browser): handle extension relay page selection 2026-01-15 11:16:53 +00:00
Peter Steinberger
8daab932a2 chore(macos): prep Sparkle release 2026.1.14-1 2026-01-15 11:15:41 +00:00
Peter Steinberger
3b7b45c29e chore: align plugin versions 2026-01-15 10:51:16 +00:00
Peter Steinberger
3a446dd400 style: oxfmt 2026-01-15 10:48:04 +00:00
Peter Steinberger
876efb11e7 docs(changelog): note chrome relay fixes 2026-01-15 10:46:48 +00:00
Peter Steinberger
c7615aa559 fix(browser): improve chrome relay tab selection 2026-01-15 10:44:11 +00:00
Peter Steinberger
6042485367 fix(browser): default chrome profile to host 2026-01-15 10:43:39 +00:00
Peter Steinberger
725c340257 docs(gog): prefer body-file for multi-line email 2026-01-15 10:43:07 +00:00
Peter Steinberger
9abd7ceda2 test: cover dynamic command arg menus 2026-01-15 10:25:35 +00:00
Peter Steinberger
cf81cb942b fix: guard Slack cmdarg decode 2026-01-15 10:25:35 +00:00
Peter Steinberger
9097ef90b7 docs(browser): clarify chrome extension profile usage 2026-01-15 10:22:29 +00:00
Peter Steinberger
4f1a4ab072 feat(browser): add snapshot refs=aria mode 2026-01-15 10:22:29 +00:00
Peter Steinberger
0facc63019 fix(skills): improve summarize selection 2026-01-15 10:14:59 +00:00
Peter Steinberger
95735c3978 chore: bump version to 2026.1.14-1 2026-01-15 10:00:43 +00:00
Peter Steinberger
d5d33d4848 fix(browser): persist role refs per targetId 2026-01-15 09:56:24 +00:00
Peter Steinberger
84e9401d53 fix(ci): repair format + android tests 2026-01-15 09:50:18 +00:00
Peter Steinberger
84bee3d7d0 Merge pull request #936 from clawdbot/shadow/dynamic-command-args
Commands: add dynamic arg menus
2026-01-15 09:44:08 +00:00
Peter Steinberger
6ff3c39989 test: add command arg parsing coverage (#936) (thanks @thewilloftheshadow) 2026-01-15 09:38:21 +00:00
Peter Steinberger
5c8239a2a9 docs(agents): note plugin-only deps 2026-01-15 09:38:01 +00:00
Peter Steinberger
f9170c5d02 fix(browser): keep tab stable across snapshot and act 2026-01-15 09:36:48 +00:00
Peter Steinberger
6ccb19e274 chore: regen protocol swift models (#936) (thanks @thewilloftheshadow) 2026-01-15 09:33:31 +00:00
Peter Steinberger
52f876bfbc fix: native command arg menus follow-ups (#936) (thanks @thewilloftheshadow) 2026-01-15 09:33:31 +00:00
Peter Steinberger
415ff7f483 chore(workspace): include extensions in workspace 2026-01-15 09:31:18 +00:00
Shadow
74bc5bfd7c Commands: add dynamic arg menus 2026-01-15 09:31:16 +00:00
Peter Steinberger
7e1e7ba2d8 fix(agents): skip thinking tags in code spans 2026-01-15 09:23:56 +00:00
Peter Steinberger
aac5b4673f docs: clarify Brave Search API key plan 2026-01-15 09:17:24 +00:00
Peter Steinberger
c86b257d38 docs(tools): hint chrome extension in browser tool prompt 2026-01-15 09:13:36 +00:00
Peter Steinberger
4291d56e0b chore: format + fix telegram thread ids 2026-01-15 09:13:19 +00:00
Peter Steinberger
5599603bdb feat: offer local plugin install in git checkouts 2026-01-15 09:07:48 +00:00
Peter Steinberger
e0f69a2294 docs: align matrix changelog to first release 2026-01-15 09:07:48 +00:00
Peter Steinberger
510915a801 fix(onboarding): wait for gateway before health 2026-01-15 09:07:30 +00:00
Peter Steinberger
609d029e20 docs(changelog): regroup 2026.1.14 2026-01-15 09:07:30 +00:00
Peter Steinberger
4275ed68a2 fix(browser): default to chrome extension takeover 2026-01-15 09:02:42 +00:00
Peter Steinberger
75d2785d20 fix(browser): make extension relay zero-config 2026-01-15 09:02:42 +00:00
Peter Steinberger
b77b47bb98 fix: use canonical main session keys in apps 2026-01-15 08:59:05 +00:00
Peter Steinberger
5f87f7bbf5 fix: macos wizard auth bootstrap 2026-01-15 08:47:45 +00:00
Peter Steinberger
1afdb850f3 chore: add matrix changelog 2026-01-15 08:40:37 +00:00
Peter Steinberger
725a6b71dc feat: add matrix channel plugin 2026-01-15 08:40:37 +00:00
Peter Steinberger
f4bb5b381d chore: note issue/pr url requirement 2026-01-15 08:39:53 +00:00
Peter Steinberger
11d4fc101e fix: sync cron run history selection in control ui 2026-01-15 08:38:21 +00:00
Peter Steinberger
4e6fb47a3f fix(changelog): merge 2026.1.14 sections 2026-01-15 08:35:25 +00:00
Peter Steinberger
a39bb4310c fix(onboarding): daemon progress + web search setup 2026-01-15 08:31:02 +00:00
Peter Steinberger
f1ac18933c fix(cli): daemon output + health colors 2026-01-15 08:31:02 +00:00
Peter Steinberger
2bae4d2dba fix(ui): override token from URL 2026-01-15 08:31:02 +00:00
Peter Steinberger
1797233989 fix(tui): surface model errors 2026-01-15 08:31:02 +00:00
Peter Steinberger
3171781d58 fix: show raw any-map entries in config UI 2026-01-15 08:24:23 +00:00
Peter Steinberger
35ddd8db5e docs: add compaction alias page 2026-01-15 08:15:33 +00:00
Peter Steinberger
c269d5f258 fix: isolate Slack thread sessions (#758) 2026-01-15 08:11:03 +00:00
Peter Steinberger
627cc1f04b fix(changelog): keep 2026.1.14 unreleased 2026-01-15 08:09:32 +00:00
Peter Steinberger
53d0bf653a fix: canonicalize main session keys 2026-01-15 08:05:41 +00:00
Peter Steinberger
a5a9788b20 fix: imessage dm replies and error details (#935) 2026-01-15 08:05:10 +00:00
Peter Steinberger
9c04a79c0a fix(ui): move docs link into nav 2026-01-15 08:01:34 +00:00
Peter Steinberger
f3519d895c fix: normalize slack channel types for sessions 2026-01-15 08:00:20 +00:00
Peter Steinberger
0ac5480034 fix(onboarding): move web search hint to end 2026-01-15 07:57:18 +00:00
Peter Steinberger
5cd48bbe7d docs: redirect /sandboxing to /gateway/sandboxing 2026-01-15 07:55:49 +00:00
Peter Steinberger
8dacafce7f fix: harden whatsapp command auth 2026-01-15 07:54:39 +00:00
Peter Steinberger
8c4b8f2c38 chore: ship dist/security in npm pack 2026-01-15 07:54:12 +00:00
Peter Steinberger
fee8bcc50b docs: hide experiments nav group 2026-01-15 07:53:43 +00:00
Peter Steinberger
cb9f4a0485 docs: link multi-gateway guide in sidebar 2026-01-15 07:49:52 +00:00
Peter Steinberger
79f340a410 chore: prep 2026.1.14 npm release 2026-01-15 07:47:18 +00:00
Peter Steinberger
081e5ef572 fix(tools): enable web_fetch by default 2026-01-15 07:42:07 +00:00
Peter Steinberger
f5577ad78e docs: add multi-gateway guide 2026-01-15 07:29:48 +00:00
Peter Steinberger
b2b1c1de9c Merge pull request #846 from vrknetha/feature/voice-call-plivo
feat(voice-call): add Plivo provider (no SDK dependency)
2026-01-15 07:28:37 +00:00
Peter Steinberger
3e6917c8ae fix: restore notify init + Plivo numbers (#846) (thanks @vrknetha) 2026-01-15 07:28:14 +00:00
Peter Steinberger
564b6aa2aa docs: clarify Telegram IPv6 DNS troubleshooting 2026-01-15 07:23:28 +00:00
Peter Steinberger
eb3eb3c39e test: clear config overrides in unit tests 2026-01-15 07:23:00 +00:00
vrknetha
2579609922 Voice Call: fix Plivo webhook method typing 2026-01-15 07:21:40 +00:00
vrknetha
946b0229e8 Voice Call: add Plivo provider 2026-01-15 07:21:40 +00:00
Peter Steinberger
0a1eeedc10 fix: unblock control commands during active runs 2026-01-15 07:08:48 +00:00
Peter Steinberger
5a58feefdc chore: enforce plugin version sync 2026-01-15 07:08:33 +00:00
Peter Steinberger
48b15bd099 chore: sync plugin versions 2026-01-15 07:07:48 +00:00
Peter Steinberger
1c96477686 fix: harden session cache + heartbeat restore
Co-authored-by: Ronak Guliani <ronak-guliani@users.noreply.github.com>
2026-01-15 07:07:12 +00:00
Peter Steinberger
04ebf8d67a docs: note child process bridge helper 2026-01-15 06:59:02 +00:00
Peter Steinberger
82d83a9d54 Merge pull request #852 from mneves75/fix/cron-settings-channel-param
fix(macos): use channel param in CronSettings+Testing
2026-01-15 06:38:33 +00:00
Peter Steinberger
66de8aedeb fix: satisfy swiftformat lint (#852) (thanks @mneves75) 2026-01-15 06:38:15 +00:00
Peter Steinberger
154b8e3e0e fix: bridge respawned child signals (#933) (thanks @roshanasingh4)
Co-authored-by: Roshan Singh <roshanasingh4@users.noreply.github.com>
2026-01-15 06:37:27 +00:00
Roshan Singh
d9f2ee40f7 Fix entry respawn signal forwarding
Fixes #931
2026-01-15 06:33:28 +00:00
Peter Steinberger
6bcb89cf38 Merge pull request #882 from chrisrodz/feat/whatsapp-send-read-receipts-option
feat(whatsapp): add sendReadReceipts config option
2026-01-15 06:27:35 +00:00
Peter Steinberger
3475ecde6f docs: expand Zalo overview + add longmaba
Co-authored-by: Long <longmaba@gmail.com>
2026-01-15 06:26:55 +00:00
Peter Steinberger
728cd5e974 fix: document WhatsApp read receipts toggle (#882) (thanks @chrisrodz) 2026-01-15 06:22:33 +00:00
Christian A. Rodriguez
0fab14ad9d style: fix CronModels.swift indentation
- Move CronPayload enum to top level (was incorrectly nested)
- Fix all tabs to spaces
- Align case statements consistently
2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
d23febcd6a style: fix tabs in CronModels.swift 2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
d763926364 style: fix biome formatting 2026-01-15 06:22:09 +00:00
Christian A. Rodriguez
7a683a4b62 feat(whatsapp): add sendReadReceipts config option
Add option to disable automatic read receipts for WhatsApp messages.
When set to false, Clawdbot will not mark messages as read (blue ticks).

Closes #344

Changes:
- Add sendReadReceipts to WhatsAppConfig and WhatsAppAccountConfig types
- Add sendReadReceipts to zod schemas for validation
- Add sendReadReceipts to ResolvedWhatsAppAccount with fallback chain
- Pass sendReadReceipts through to monitorWebInbox
- Gate sock.readMessages() call based on config option

Default behavior (true) is preserved - only explicitly setting false
will disable read receipts.
2026-01-15 06:22:09 +00:00
Peter Steinberger
44a237b637 feat(browser): copy extension path to clipboard 2026-01-15 06:19:47 +00:00
Peter Steinberger
375304bf13 test: remove legacy config migration test 2026-01-15 06:18:44 +00:00
Peter Steinberger
d59aab7fd3 chore: drop Clawdis legacy references 2026-01-15 06:18:44 +00:00
Peter Steinberger
60748b1370 docs: refresh clawtributors list 2026-01-15 06:17:02 +00:00
Peter Steinberger
624cb33534 feat: expand Telegram allowFrom guidance
Co-authored-by: Christoph Nakazawa <cpojer@users.noreply.github.com>
2026-01-15 06:15:06 +00:00
Peter Steinberger
9603eff79a Merge pull request #909 from roshanasingh4/fix/macos-wizard-gateway-logdir
macOS: fix wizard hang when /tmp/clawdbot is missing
2026-01-15 06:13:51 +00:00
Peter Steinberger
e9d6dec2f4 fix: make log dir overrideable in tests (#909) (thanks @roshanasingh4) 2026-01-15 06:13:20 +00:00
Peter Steinberger
0cbfea79fa docs(cli): add per-command CLI pages 2026-01-15 06:13:10 +00:00
Roshan Singh
aaae327563 macOS: ensure /tmp/clawdbot exists for launchd logs 2026-01-15 06:10:05 +00:00
Peter Steinberger
5b23f847d6 docs: clarify Telegram allowFrom lookup 2026-01-15 06:09:19 +00:00
Peter Steinberger
5a55789bc1 Merge pull request #859 from CashWilliams/main
fix: Make timezone and 24 hour clock explicit in system prompt
2026-01-15 06:08:51 +00:00
Peter Steinberger
9c396f6331 fix: note timezone + 24h prompt update (#859) (thanks @CashWilliams) 2026-01-15 06:08:43 +00:00
Cash Williams
51e871f9e5 Make timezone and 24 hour clock explicit in system prompt 2026-01-15 06:08:43 +00:00
Peter Steinberger
47634c294d style: format pi embedded utils 2026-01-15 06:08:17 +00:00
Peter Steinberger
9c1122def0 test: fix Windows security audit perms 2026-01-15 06:04:39 +00:00
Peter Steinberger
2bd9e84851 fix(agents): strip tool leak text (#905)
Thanks @erikpr1994.

Co-authored-by: Erik Pastor Rios <erikpastorrios1994@gmail.com>
2026-01-15 05:58:02 +00:00
Erik
5c2eedc340 test: add thought tag stripping test case 2026-01-15 05:58:02 +00:00
Erik
3b7d103758 fix(agent): strip thinking tags from text content 2026-01-15 05:58:02 +00:00
Erik
8146c43aa3 fix(agents): strip leaked tool call text from assistant messages
When replaying conversation history to Gemini, tool calls without
thought_signature are downgraded to text blocks like [Tool Call: ...].
This leaked internal technical info into user-facing chat messages.

Added stripDowngradedToolCallText filter alongside existing Minimax
filter to remove these text representations before extraction.
2026-01-15 05:58:02 +00:00
Peter Steinberger
df386927ce docs: prune internal notes and doc aliases 2026-01-15 05:55:28 +00:00
Peter Steinberger
0c18b2c442 feat(status): add security audit section 2026-01-15 05:52:01 +00:00
Peter Steinberger
1a7b7eba59 docs(lore): mention Warelay + Clawdis 2026-01-15 05:52:01 +00:00
Peter Steinberger
54fb59b8f3 feat: extend Telegram dock commands and config hashing (#929)
Thanks @grp06.

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 05:49:28 +00:00
Peter Steinberger
a2f0d335f4 docs: add Zalo channel nav + plugin note 2026-01-15 05:36:31 +00:00
Peter Steinberger
2e70c3ceab fix: return setup hint when web_search lacks key 2026-01-15 05:35:22 +00:00
Peter Steinberger
ca1902fb4e feat(security): expand audit and safe --fix 2026-01-15 05:31:43 +00:00
Peter Steinberger
f11a89031b chore: format reply dispatcher 2026-01-15 05:31:03 +00:00
Peter Steinberger
3c22fab679 docs(cli): add dedicated browser command page 2026-01-15 05:30:03 +00:00
Peter Steinberger
ad46e95df9 test: relax browser act contract timeout on Windows 2026-01-15 05:29:37 +00:00
Peter Steinberger
7d4f2d9aed Merge pull request #928 from sebslight/feature/response-prefix-template-variables
feat: add dynamic template variables to messages.responsePrefix
2026-01-15 05:27:20 +00:00
Peter Steinberger
738b3592cd fix: remove conflict marker in google helper (#875) 2026-01-15 05:25:45 +00:00
Peter Steinberger
cd2af64860 fix: cap tool call IDs for OpenAI/OpenRouter (#875) (thanks @j1philli) 2026-01-15 05:25:45 +00:00
Josh Phillips
04f1e767b2 Fix OpenAI tool_call id length for OpenRouter 2026-01-15 05:25:45 +00:00
Peter Steinberger
1c7ac2a6ab test: fix windows-only expectations 2026-01-15 05:25:08 +00:00
Peter Steinberger
77cf40da87 feat: profile-aware gateway service names (#671)
Thanks @bjesuiter.

Co-authored-by: Benjamin Jesuiter <bjesuiter@gmail.com>
2026-01-15 05:23:41 +00:00
Peter Steinberger
1fe8df85cb docs: clarify extension tab attachment 2026-01-15 05:20:56 +00:00
Peter Steinberger
139f80a291 chore: format sources and update protocol outputs 2026-01-15 05:17:19 +00:00
Peter Steinberger
2d066b8715 docs: explain sandboxed browser control 2026-01-15 05:17:12 +00:00
Peter Steinberger
757243993c fix: stabilize gateway config tests + tool schema 2026-01-15 05:16:28 +00:00
Peter Steinberger
4fb114dcb3 feat: improve extension options diagnostics 2026-01-15 05:15:33 +00:00
Peter Steinberger
612cdac4c3 docs: clarify when browser serve is needed 2026-01-15 05:15:33 +00:00
Peter Steinberger
57c66fe813 fix: clean up onboarding + channel selection types 2026-01-15 05:12:33 +00:00
Peter Steinberger
9c02ea9098 feat: polish chrome extension UX 2026-01-15 05:11:03 +00:00
Peter Steinberger
042b65dfcc feat: add web tools config to configure 2026-01-15 05:08:56 +00:00
Peter Steinberger
f6a72ef3c2 feat: improve browser extension install output 2026-01-15 05:05:27 +00:00
Peter Steinberger
568cc368ae feat: add onboarding plugin install flow 2026-01-15 05:04:09 +00:00
Peter Steinberger
5abe3c2145 feat: add plugin HTTP hooks + Zalo plugin 2026-01-15 05:04:09 +00:00
Peter Steinberger
0e76d21f11 docs(security): mention audit --fix 2026-01-15 05:03:13 +00:00
Peter Steinberger
5e8693bc42 chore: bump pi packages to 0.45.7 2026-01-15 04:55:40 +00:00
Peter Steinberger
ef78b198cb feat: add Chrome extension browser relay 2026-01-15 04:52:28 +00:00
Peter Steinberger
5fdaef3646 fix: downgrade unsigned gemini thinking 2026-01-15 04:51:21 +00:00
Peter Steinberger
fa4670c5fe feat: improve agent auth guidance 2026-01-15 04:51:21 +00:00
Peter Steinberger
c4402a1ce5 docs: clarify agent auth + sandboxed skills 2026-01-15 04:51:03 +00:00
Peter Steinberger
edd8c613d6 feat(security): add audit --fix 2026-01-15 04:50:06 +00:00
Peter Steinberger
0a7f5bf6a5 test(gateway): fix config.patch baseHash fixtures 2026-01-15 04:50:06 +00:00
Peter Steinberger
eb3e865f15 fix(build): restore tool policy typing 2026-01-15 04:50:06 +00:00
Roshan Singh
1baa55c145 Structured subagent announce output + include run outcome (#835)
* docs: clarify subagent announce status

* Make subagent announce structured and include run outcome

* fix: stabilize sub-agent announce status (#835) (thanks @roshanasingh4)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 04:48:07 +00:00
Sebastian
6ef3837e73 Remove debug logging for responsePrefix template resolution 2026-01-14 23:36:47 -05:00
Sebastian
e7167e35ed debug: use console.log instead of logVerbose for always-visible logging 2026-01-14 23:29:17 -05:00
Peter Steinberger
2e0325e3bf feat: add web search hint to onboarding 2026-01-15 04:25:19 +00:00
Sebastian
56b3b44342 debug: add responsePrefix template logging 2026-01-14 23:23:21 -05:00
Sebastian
113eea5047 fix: mutate prefixContext object instead of reassigning for closure correctness 2026-01-14 23:20:19 -05:00
Peter Steinberger
dcadaad228 docs: add web search note to quick start 2026-01-15 04:16:09 +00:00
Sebastian
7b04e6ac42 debug: add prefix template resolution logging 2026-01-14 23:15:46 -05:00
Peter Steinberger
1533868680 docs: add web search FAQ 2026-01-15 04:15:24 +00:00
Peter Steinberger
f275cc180b feat: add web tools 2026-01-15 04:07:40 +00:00
Peter Steinberger
31d3aef8d6 fix: prevent config clobbering 2026-01-15 04:06:11 +00:00
Peter Steinberger
bd467ff765 chore: mark 2026.1.14 as unreleased 2026-01-15 04:05:18 +00:00
Sebastian
d0a4cce41e feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).

Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name

Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"

Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.

Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
  telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc

Fixes #923
2026-01-14 23:05:08 -05:00
Peter Steinberger
429f973280 test: cover browser snapshot labels and efficient mode 2026-01-15 04:04:30 +00:00
Peter Steinberger
6320f739d4 refactor: centralize whatsapp auth detection 2026-01-15 04:01:06 +00:00
Peter Steinberger
1732932c57 fix: unblock launchctl stub on windows 2026-01-15 03:58:32 +00:00
Peter Steinberger
574b6ab5b1 docs: document provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
1c737f88fe test: cover provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
fa8d9b9189 feat: add provider-specific tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger
512dbedee3 chore: bump Peekaboo submodule 2026-01-15 03:54:06 +00:00
Peter Steinberger
db21c2d397 docs: clarify group sandbox folder allowlist 2026-01-15 03:52:57 +00:00
Peter Steinberger
1078d178d7 fix: doctor ack reaction migration (#927)
Thanks @grp06.

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 03:51:55 +00:00
Peter Steinberger
a6e780b2f6 feat: add browser snapshot modes 2026-01-15 03:50:57 +00:00
Peter Steinberger
4e48d0a431 docs: document personal DMs vs public groups 2026-01-15 03:48:14 +00:00
Peter Steinberger
c289a88f50 fix(security): force hono 4.11.4 2026-01-15 03:40:02 +00:00
Peter Steinberger
4ce495cb2c Merge pull request #849 from ndraiman/fix/cli-launchd-enable-before-bootstrap
fix(daemon): enable launchd service before bootstrap
2026-01-15 03:36:14 +00:00
Peter Steinberger
dfea2991c9 fix(daemon): clear launchd disabled state before bootstrap (#849) (thanks @ndraiman) 2026-01-15 03:35:24 +00:00
Peter Steinberger
6f5fc2276a chore(packaging): align workspace patch version 2026-01-15 03:33:00 +00:00
Netanel Draiman
d51a9ebb0e fix(daemon): enable launchd service before bootstrap
When a launchd service is uninstalled via bootout, macOS marks it as
disabled in /var/db/com.apple.xpc.launchd/disabled.*.plist. This persists
across reboots and plist recreation. Future bootstrap calls fail with
error 5 (I/O error) because the service is still marked disabled.

Fix: Call 'launchctl enable' before 'launchctl bootstrap' to clear the
disabled state, matching the fix already applied to the macOS Swift app
in GatewayLaunchAgentManager.swift.

Related: #306
2026-01-15 03:31:52 +00:00
Peter Steinberger
536d3d76a3 Merge pull request #925 from grp06/fix/console-eio
Logging: tolerate EIO console writes (AI)
2026-01-15 03:28:56 +00:00
Peter Steinberger
5c52dbf661 style: oxfmt fixes (#925) (thanks @grp06) 2026-01-15 03:22:54 +00:00
George Pickett
8f797f213e Logging: tolerate EIO console writes 2026-01-15 03:20:48 +00:00
Peter Steinberger
ed68f378d7 Merge pull request #227 from Hyaxia/detect-secrets
add detect-secrets CI job + baseline guidance
2026-01-15 03:17:14 +00:00
Peter Steinberger
7e8de907f8 fix: note detect-secrets CI scan (#227) (thanks @Hyaxia) 2026-01-15 03:16:41 +00:00
hyaxia
f3c9252840 Security: add detect-secrets scan 2026-01-15 03:14:43 +00:00
Peter Steinberger
da9e27f466 docs: update changelog for telegram aggregation 2026-01-15 03:05:46 +00:00
Peter Steinberger
aa74e28112 fix(telegram): aggregate split inbound messages 2026-01-15 03:04:59 +00:00
Peter Steinberger
e569f15631 fix: scrub tuple items schemas for Gemini tools (#926) — thanks @grp06
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 02:59:35 +00:00
Peter Steinberger
eaace34233 fix: restore docker binds and PATH in sandbox exec (#873)
Thanks @akonyer.

Co-authored-by: Aaron Konyer <aaronk@gomodular.ca>
2026-01-15 02:58:20 +00:00
Peter Steinberger
7a839e7eb6 docs: add messaging channel guide 2026-01-15 02:50:03 +00:00
Peter Steinberger
1b24b6a02b fix: add plugin runtime registry 2026-01-15 02:44:45 +00:00
Peter Steinberger
2b4a68e276 feat: load channel plugins 2026-01-15 02:42:44 +00:00
Peter Steinberger
b1e3d79eaa docs: clarify Claude CLI auth mode 2026-01-15 02:35:20 +00:00
Peter Steinberger
2fb2035dbf fix: normalize Claude CLI auth mode to oauth (#855)
Thanks @sebslight.

Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-15 02:29:43 +00:00
Peter Steinberger
3c51290e0d Merge pull request #850 from evalexpr/fix/slack-top-level-require-mention
fix(slack): respect top-level requireMention config
2026-01-15 02:28:25 +00:00
Peter Steinberger
765196d5c3 style(slack): satisfy oxfmt (#850) 2026-01-15 02:23:22 +00:00
Peter Steinberger
12c2d37e62 docs: document DM history limits (#883)
Thanks @pkrmf.

Co-authored-by: Marc Terns <tenxurz@gmail.com>
2026-01-15 02:22:29 +00:00
Cash Williams
5283872e00 Make timezone and 24 hour clock explicit in system prompt 2026-01-13 12:42:37 -06:00
934 changed files with 60884 additions and 24862 deletions

26
.detect-secrets.cfg Normal file
View File

@@ -0,0 +1,26 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"

View File

@@ -141,6 +141,31 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows:
runs-on: blacksmith-4vcpu-windows-2025
defaults:

518
.secrets.baseline Normal file
View File

@@ -0,0 +1,518 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"(^|/)pnpm-lock\\.yaml$"
]
},
{
"path": "detect_secrets.filters.regex.should_exclude_line",
"pattern": [
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
"case apikey = \"apiKey\"",
"\"gateway\\.remote\\.password\"",
"\"gateway\\.auth\\.password\"",
"\"talk\\.apiKey\"",
"=== \"string\"",
"typeof remote\\?\\.password === \"string\""
]
}
],
"results": {
".env.example": [
{
"type": "Twilio API Key",
"filename": ".env.example",
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
"is_verified": false,
"line_number": 2
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
"is_verified": false,
"line_number": 19
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
"is_verified": false,
"line_number": 52
}
],
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
"is_verified": false,
"line_number": 26
},
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
"is_verified": false,
"line_number": 42
}
],
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 83
}
],
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 27
}
],
"docs/configuration.md": [
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 268
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 465
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 718
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 760
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 859
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 982
}
],
"docs/faq.md": [
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 593
},
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 650
}
],
"docs/skills-config.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills-config.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 28
}
],
"docs/skills.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 97
}
],
"docs/tailscale.md": [
{
"type": "Secret Keyword",
"filename": "docs/tailscale.md",
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
"is_verified": false,
"line_number": 52
}
],
"docs/talk.md": [
{
"type": "Secret Keyword",
"filename": "docs/talk.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 50
}
],
"docs/telegram.md": [
{
"type": "Secret Keyword",
"filename": "docs/telegram.md",
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
"is_verified": false,
"line_number": 57
}
],
"skills/local-places/SERVER_README.md": [
{
"type": "Secret Keyword",
"filename": "skills/local-places/SERVER_README.md",
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
"is_verified": false,
"line_number": 28
}
],
"skills/openai-whisper-api/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/openai-whisper-api/SKILL.md",
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
"is_verified": false,
"line_number": 39
}
],
"skills/trello/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/trello/SKILL.md",
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
"is_verified": false,
"line_number": 18
}
],
"src/agents/models-config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
"is_verified": false,
"line_number": 90
}
],
"src/agents/skills.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 158
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
"is_verified": false,
"line_number": 265
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
"is_verified": false,
"line_number": 462
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
"is_verified": false,
"line_number": 490
}
],
"src/browser/target-id.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/browser/target-id.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 16
}
],
"src/commands/antigravity-oauth.ts": [
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
"is_verified": false,
"line_number": 17
},
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
"is_verified": false,
"line_number": 20
}
],
"src/commands/onboard-auth.ts": [
{
"type": "Secret Keyword",
"filename": "src/commands/onboard-auth.ts",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 50
}
],
"src/config/config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/config/config.test.ts",
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
"is_verified": false,
"line_number": 520
}
],
"src/gateway/server.auth.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 89
},
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
"is_verified": false,
"line_number": 109
}
],
"src/infra/env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
"is_verified": false,
"line_number": 10
},
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
"is_verified": false,
"line_number": 25
}
],
"src/infra/shell-env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
"is_verified": false,
"line_number": 56
},
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
"is_verified": false,
"line_number": 73
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
"is_verified": false,
"line_number": 75
}
],
"src/web/qr-image.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/web/qr-image.test.ts",
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
"is_verified": false,
"line_number": 12
}
],
"vendor/a2ui/README.md": [
{
"type": "Secret Keyword",
"filename": "vendor/a2ui/README.md",
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
"is_verified": false,
"line_number": 123
}
]
},
"generated_at": "2026-01-05T13:01:00Z"
}

View File

@@ -6,6 +6,7 @@
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
## Docs Linking (Mintlify)
@@ -59,6 +60,9 @@
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
## Shorthand Commands
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
@@ -70,10 +74,11 @@
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
@@ -111,6 +116,14 @@
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:

View File

@@ -1,15 +1,93 @@
# Changelog
## 2026.1.15
## 2026.1.15 (unreleased)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
## 2026.1.14-1
### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`.
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
- Security: add `SECURITY.md` reporting policy.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14
@@ -25,7 +103,28 @@
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
#### Agents / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
@@ -47,6 +146,7 @@
### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
## 2026.1.12-1
@@ -68,6 +168,7 @@
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
@@ -80,6 +181,7 @@
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.

View File

@@ -468,25 +468,29 @@ AI/vibe-coded PRs welcome! 🤖
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
Core contributors:
- @cpojer — Telegram onboarding UX + docs
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a>
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a>
<a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

15
SECURITY.md Normal file
View File

@@ -0,0 +1,15 @@
# Security Policy
If you believe you’ve found a security issue in Clawdbot, please report it privately.
## Reporting
- Email: `steipete@gmail.com`
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
## Operational Guidance
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`

View File

@@ -34,8 +34,7 @@ extension AttributedString {
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)

View File

@@ -13,8 +13,7 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8)
{
let text = String(data: data, encoding: .utf8) {
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}

View File

@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
@@ -57,6 +56,12 @@ public enum WakeWordGate {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
@@ -68,7 +73,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
var best: MatchCandidate?
for trigger in triggerTokens {
let count = trigger.tokens.count
@@ -84,7 +89,7 @@ public enum WakeWordGate {
if let best, i <= best.index { continue }
best = (i, triggerEnd, gap)
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}

View File

@@ -24,7 +24,7 @@ enum CLIRegistry {
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self),
descriptor(for: ServiceStatus.self)
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc,
stopDesc,
restartDesc,
statusDesc,
statusDesc
])
return [root]
}

View File

@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true,
"KeepAlive": true
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)

View File

@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first {
case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await dispatchSwabble(parsed: parsed, path: path)
default:
throw CommanderProgramError.unknownCommand(first)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)

View File

@@ -2,6 +2,178 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5825</sparkle:version>
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
<h3>Highlights</h3>
<ul>
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
</ul>
<h3>Changes</h3>
<h4>Web Tools</h4>
<ul>
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
</ul>
<h4>Browser / Control UI</h4>
<ul>
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
</ul>
<h4>Plugins</h4>
<ul>
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
</ul>
<h4>Security</h4>
<ul>
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
</ul>
<h4>Onboarding / Daemon</h4>
<ul>
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
</ul>
<h4>Auth / Usage / Config</h4>
<ul>
<li>Usage: add MiniMax coding plan usage tracking.</li>
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
</ul>
<h4>Channels</h4>
<ul>
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
</ul>
<h4>Docs</h4>
<ul>
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
</ul>
<h3>Fixes</h3>
<h4>Gateway / Daemon / Sessions</h4>
<ul>
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
</ul>
<h4>CLI / Onboarding</h4>
<ul>
<li>Onboarding: show web search setup at the end (not the beginning).</li>
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
</ul>
<h4>Control UI / TUI</h4>
<ul>
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
</ul>
<h4>Agents / Auth / Tools / Sandbox</h4>
<ul>
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
</ul>
<h4>macOS / Apps</h4>
<ul>
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
</ul>
<h4>Channels / Messaging</h4>
<ul>
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
<li>WhatsApp: harden owner command auth.</li>
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
</ul>
<h4>Config / Doctor / Packaging</h4>
<ul>
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
@@ -35,22 +207,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
<item>
<title>2026.1.11-2</title>
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5210</sparkle:version>
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</h2>
<h3>Fixes</h3>
<ul>
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
</item>
</channel>
</rss>

View File

@@ -119,7 +119,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
}
tasks.withType<Test>().configureEach {

View File

@@ -27,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
@@ -138,7 +139,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}
fun loadChat(sessionKey: String = "main") {
fun loadChat(sessionKey: String) {
runtime.loadChat(sessionKey)
}

View File

@@ -16,6 +16,7 @@ import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeTlsParams
import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig
@@ -78,7 +79,7 @@ class NodeRuntime(context: Context) {
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(command))
put("sessionKey", JsonPrimitive(mainSessionKey.value))
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
put("thinking", JsonPrimitive(chatThinkingLevel.value))
put("deliver", JsonPrimitive(false))
}.toString(),
@@ -142,12 +143,13 @@ class NodeRuntime(context: Context) {
private val session =
BridgeSession(
scope = scope,
onConnected = { name, remote ->
onConnected = { name, remote, mainSessionKey ->
_statusText.value = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
applyMainSessionKey(mainSessionKey)
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect()
@@ -159,6 +161,9 @@ class NodeRuntime(context: Context) {
onInvoke = { req ->
handleInvoke(req.command, req.paramsJson)
},
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
},
)
private val chat = ChatController(scope = scope, session = session, json = json)
@@ -172,11 +177,31 @@ class NodeRuntime(context: Context) {
_remoteAddress.value = null
_isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_mainSessionKey.value = "main"
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
}
val mainKey = resolveMainSessionKey()
talkMode.setMainSessionKey(mainKey)
chat.applyMainSessionKey(mainKey)
chat.onDisconnected(message)
showLocalCanvasOnDisconnect()
}
private fun applyMainSessionKey(candidate: String?) {
val trimmed = candidate?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
if (_mainSessionKey.value == trimmed) return
_mainSessionKey.value = trimmed
talkMode.setMainSessionKey(trimmed)
chat.applyMainSessionKey(trimmed)
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
}
private fun maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
@@ -467,12 +492,17 @@ class NodeRuntime(context: Context) {
scope.launch {
_statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint)
val resolved =
if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…"
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
@@ -489,6 +519,7 @@ class NodeRuntime(context: Context) {
session.connect(
endpoint = endpoint,
hello = buildSessionHello(token = authToken),
tls = tls,
)
}
}
@@ -535,6 +566,41 @@ class NodeRuntime(context: Context) {
session.disconnect()
}
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
val manual = endpoint.stableId.startsWith("manual|")
if (hinted) {
return BridgeTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (!stored.isNullOrBlank()) {
return BridgeTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = endpoint.stableId,
)
}
if (manual) {
return BridgeTlsParams(
required = false,
expectedFingerprint = null,
allowTOFU = true,
stableId = endpoint.stableId,
)
}
return null
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch {
val trimmed = payloadJson.trim()
@@ -559,7 +625,7 @@ class NodeRuntime(context: Context) {
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = "main"
val sessionKey = resolveMainSessionKey()
val message =
ClawdbotCanvasA2UIAction.formatAgentMessage(
actionName = name,
@@ -607,8 +673,9 @@ class NodeRuntime(context: Context) {
}
}
fun loadChat(sessionKey: String = "main") {
chat.load(sessionKey)
fun loadChat(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
chat.load(key)
}
fun refreshChat() {
@@ -701,7 +768,7 @@ class NodeRuntime(context: Context) {
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
_mainSessionKey.value = mainKey
applyMainSessionKey(mainKey)
val parsed = parseHexColorArgb(raw)
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB

View File

@@ -147,6 +147,16 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, token.trim()) }
}
fun loadBridgeTlsFingerprint(stableId: String): String? {
val key = "bridge.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
val key = "bridge.tls.$stableId"
prefs.edit { putString(key, fingerprint.trim()) }
}
private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing

View File

@@ -4,3 +4,10 @@ internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}

View File

@@ -143,6 +143,8 @@ class BridgeDiscovery(
val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val id = stableId(serviceName, "local.")
localById[id] =
BridgeEndpoint(
@@ -155,6 +157,8 @@ class BridgeDiscovery(
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
publish()
}
@@ -209,6 +213,11 @@ class BridgeDiscovery(
return txt(info, key)?.toIntOrNull()
}
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
@@ -252,6 +261,8 @@ class BridgeDiscovery(
val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val id = stableId(instanceName, domain)
next[id] =
BridgeEndpoint(
@@ -264,6 +275,8 @@ class BridgeDiscovery(
gatewayPort = gatewayPort,
bridgePort = bridgePort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
}
@@ -474,6 +487,11 @@ class BridgeDiscovery(
return txtValue(records, key)?.toIntOrNull()
}
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.

View File

@@ -10,6 +10,8 @@ data class BridgeEndpoint(
val gatewayPort: Int? = null,
val bridgePort: Int? = null,
val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) {
companion object {
fun manual(host: String, port: Int): BridgeEndpoint =
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
name = "$host:$port",
host = host,
port = port,
tlsEnabled = false,
tlsFingerprintSha256 = null,
)
}
}

View File

@@ -14,7 +14,6 @@ import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true }
@@ -33,95 +32,120 @@ class BridgePairingClient {
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
suspend fun pairAndHello(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams? = null,
onTlsFingerprint: ((String) -> Unit)? = null,
): PairResult =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message)
} finally {
if (tls != null) {
try {
socket.close()
} catch (_: Throwable) {
// ignore
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
} catch (e: Exception) {
if (tls.required) throw e
}
}
pairAndHelloWithTls(endpoint, hello, null, null)
}
private fun pairAndHelloWithTls(
endpoint: BridgeEndpoint,
hello: Hello,
tls: BridgeTlsParams?,
onTlsFingerprint: ((String) -> Unit)?,
): PairResult {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(fingerprint)
}
socket.tcpNoDelay = true
try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
fun send(line: String) {
writer.write(line)
writer.write("\n")
writer.flush()
}
fun sendJson(obj: JsonObject) = send(obj.toString())
sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
return when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return PairResult(ok = false, token = null, error = "$code: $message")
}
sendJson(
buildJsonObject {
put("type", JsonPrimitive("pair-request"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
while (true) {
val nextLine = reader.readLine() ?: break
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
when (next["type"].asStringOrNull()) {
"pair-ok" -> {
val token = next["token"].asStringOrNull()
return PairResult(ok = !token.isNullOrBlank(), token = token)
}
"error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed"
return PairResult(ok = false, token = null, error = "$c: $m")
}
}
}
PairResult(ok = false, token = null, error = "pairing failed")
}
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
}
} catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
return PairResult(ok = false, token = null, error = message)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -31,10 +31,11 @@ import java.util.concurrent.ConcurrentHashMap
class BridgeSession(
private val scope: CoroutineScope,
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
data class Hello(
val nodeId: String,
@@ -64,12 +65,19 @@ class BridgeSession(
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: String? = null
private var desired: Pair<BridgeEndpoint, Hello>? = null
private data class DesiredConnection(
val endpoint: BridgeEndpoint,
val hello: Hello,
val tls: BridgeTlsParams?,
)
private var desired: DesiredConnection? = null
private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
desired = endpoint to hello
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
desired = DesiredConnection(endpoint, hello, tls)
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
@@ -77,7 +85,7 @@ class BridgeSession(
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.first to hello
desired = target.copy(hello = hello)
val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello))
}
@@ -90,11 +98,13 @@ class BridgeSession(
job?.cancelAndJoin()
job = null
canvasHostUrl = null
mainSessionKey = null
onDisconnected("Offline")
}
}
fun currentCanvasHostUrl(): String? = canvasHostUrl
fun currentMainSessionKey(): String? = mainSessionKey
suspend fun sendEvent(event: String, payloadJson: String?) {
val conn = currentConnection ?: return
@@ -162,10 +172,10 @@ class BridgeSession(
continue
}
val (endpoint, hello) = target
val (endpoint, hello, tls) = target
try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello)
connectOnce(endpoint, hello, tls)
attempt = 0
} catch (err: Throwable) {
attempt += 1
@@ -189,58 +199,76 @@ class BridgeSession(
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
}
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
withContext(Dispatchers.IO) {
val socket = Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
android.util.Log.d(
"ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
)
}
}
onConnected(name, conn.remoteAddress)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
if (tls != null) {
try {
connectWithSocket(endpoint, hello, tls)
return@withContext
} catch (err: Throwable) {
if (tls.required) throw err
}
}
connectWithSocket(endpoint, hello, null)
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
val socket =
createBridgeSocket(tls) { fingerprint ->
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
}
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
val conn = Connection(socket, reader, writer, writeLock)
currentConnection = conn
try {
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
?: throw IllegalStateException("unexpected bridge response")
when (first["type"].asStringOrNull()) {
"hello-ok" -> {
val name = first["serverName"].asStringOrNull() ?: "Bridge"
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
mainSessionKey = rawMainSessionKey
if (BuildConfig.DEBUG) {
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
runCatching {
android.util.Log.d(
"ClawdbotBridge",
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
)
}
}
onConnected(name, conn.remoteAddress, rawMainSessionKey)
}
"error" -> {
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
val msg = first["message"].asStringOrNull() ?: "connect failed"
throw IllegalStateException("$code: $msg")
}
else -> throw IllegalStateException("unexpected bridge response")
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: continue
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
@@ -286,20 +314,20 @@ class BridgeSession(
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {

View File

@@ -0,0 +1,81 @@
package com.clawdbot.android.bridge
import android.annotation.SuppressLint
import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
data class BridgeTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
val allowTOFU: Boolean,
val stableId: String,
)
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
if (params == null) return Socket()
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expected != null) {
if (fingerprint != expected) {
throw CertificateException("bridge TLS fingerprint mismatch")
}
return
}
if (params.allowTOFU) {
onStore?.invoke(fingerprint)
return
}
defaultTrust.checkServerTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
return context.socketFactory.createSocket()
}
fun startTlsHandshakeIfNeeded(socket: Socket) {
if (socket is SSLSocket) {
socket.startHandshake()
}
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)
val trust =
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
return trust ?: throw IllegalStateException("No default X509TrustManager found")
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
out.append(String.format("%02x", byte))
}
return out.toString()
}
private fun normalizeFingerprint(raw: String): String {
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
}

View File

@@ -71,12 +71,21 @@ class ChatController(
_sessionId.value = null
}
fun load(sessionKey: String = "main") {
fun load(sessionKey: String) {
val key = sessionKey.trim().ifEmpty { "main" }
_sessionKey.value = key
scope.launch { bootstrap(forceHealth = true) }
}
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
if (_sessionKey.value == trimmed) return
if (_sessionKey.value != "main") return
_sessionKey.value = trimmed
scope.launch { bootstrap(forceHealth = true) }
}
fun refresh() {
scope.launch { bootstrap(forceHealth = true) }
}

View File

@@ -44,6 +44,7 @@ import com.clawdbot.android.chat.ChatSessionEntry
fun ChatComposer(
sessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
healthOk: Boolean,
thinkingLevel: String,
pendingRunCount: Int,
@@ -61,7 +62,7 @@ fun ChatComposer(
var showThinkingMenu by remember { mutableStateOf(false) }
var showSessionMenu by remember { mutableStateOf(false) }
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val currentSessionLabel =
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey

View File

@@ -33,13 +33,14 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadChat("main")
LaunchedEffect(mainSessionKey) {
viewModel.loadChat(mainSessionKey)
viewModel.refreshChatSessions(limit = 200)
}
@@ -85,6 +86,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer(
sessionKey = sessionKey,
sessions = sessions,
mainSessionKey = mainSessionKey,
healthOk = healthOk,
thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount,

View File

@@ -2,20 +2,23 @@ package com.clawdbot.android.ui.chat
import com.clawdbot.android.chat.ChatSessionEntry
private const val MAIN_SESSION_KEY = "main"
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
mainSessionKey: String,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val current = currentSessionKey.trim()
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val aliasKey = if (mainKey == "main") null else "main"
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
@@ -23,13 +26,13 @@ fun resolveSessionChoices(
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
val mainEntry = sorted.firstOrNull { it.key == mainKey }
if (mainEntry != null) {
result.add(mainEntry)
included.add(MAIN_SESSION_KEY)
} else if (current == MAIN_SESSION_KEY) {
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
included.add(MAIN_SESSION_KEY)
included.add(mainKey)
} else if (current == mainKey) {
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
included.add(mainKey)
}
for (entry in recent) {

View File

@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.content.ContextCompat
import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.isCanonicalMainSessionKey
import com.clawdbot.android.normalizeMainKey
import java.net.HttpURLConnection
import java.net.URL
@@ -116,6 +117,13 @@ class TalkModeManager(
chatSubscribedSessionKey = null
}
fun setMainSessionKey(sessionKey: String?) {
val trimmed = sessionKey?.trim().orEmpty()
if (trimmed.isEmpty()) return
if (isCanonicalMainSessionKey(mainSessionKey)) return
mainSessionKey = trimmed
}
fun setEnabled(enabled: Boolean) {
if (_isEnabled.value == enabled) return
_isEnabled.value = enabled
@@ -827,7 +835,9 @@ class TalkModeManager(
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
mainSessionKey = mainKey
if (!isCanonicalMainSessionKey(mainSessionKey)) {
mainSessionKey = mainKey
}
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
voiceAliases = aliases
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId

View File

@@ -30,7 +30,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -97,7 +97,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },
@@ -167,7 +167,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.complete(Unit) },
onConnected = { _, _, _ -> connected.complete(Unit) },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
@@ -239,7 +239,7 @@ class BridgeSessionTest {
val session =
BridgeSession(
scope = scope,
onConnected = { _, _ -> connected.countDown() },
onConnected = { _, _, _ -> connected.countDown() },
onDisconnected = { /* ignore */ },
onEvent = { _, _ -> /* ignore */ },
onInvoke = { BridgeSession.InvokeResult.ok(null) },

View File

@@ -19,7 +19,7 @@ class SessionFiltersTest {
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
)
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "recent-1", "recent-2"), result)
}
@@ -29,7 +29,7 @@ class SessionFiltersTest {
val recent = now - 10 * 60 * 1000L
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
assertEquals(listOf("main", "custom"), result)
}
}

View File

@@ -10,10 +10,36 @@ actor BridgeClient {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp)
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") {
@@ -142,6 +168,18 @@ actor BridgeClient {
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private struct TimeoutError: LocalizedError, Sendable {
var purpose: String
var seconds: Int

View File

@@ -10,6 +10,7 @@ protocol BridgePairingClient: Sendable {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
}
@@ -115,9 +116,12 @@ final class BridgeConnectionController {
self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
self.startAutoConnect(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
bridgeStableID: stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
return
@@ -135,10 +139,12 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true
self.startAutoConnect(
endpoint: target.endpoint,
bridgeStableID: target.stableID,
tls: tlsParams,
token: token,
instanceId: instanceId)
}
@@ -182,6 +188,7 @@ final class BridgeConnectionController {
private func startAutoConnect(
endpoint: NWEndpoint,
bridgeStableID: String,
tls: BridgeTLSParams?,
token: String,
instanceId: String)
{
@@ -193,6 +200,7 @@ final class BridgeConnectionController {
let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tls,
onStatus: { status in
Task { @MainActor in
appModel.bridgeStatusText = status
@@ -208,6 +216,7 @@ final class BridgeConnectionController {
appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: bridgeStableID,
tls: tls,
hello: self.makeHello(token: resolvedToken))
} catch {
await MainActor.run {
@@ -217,6 +226,47 @@ final class BridgeConnectionController {
}
}
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View File

@@ -23,6 +23,8 @@ final class BridgeDiscoveryModel {
var gatewayPort: Int?
var bridgePort: Int?
var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String?
}
@@ -90,6 +92,8 @@ final class BridgeDiscoveryModel {
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath"))
default:
return nil
@@ -214,4 +218,9 @@ final class BridgeDiscoveryModel {
guard let raw = self.txtValue(dict, key: key) else { return nil }
return Int(raw)
}
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
}

View File

@@ -26,6 +26,7 @@ actor BridgeSession {
private(set) var state: State = .idle
private var canvasHostUrl: String?
private var mainSessionKey: String?
func currentCanvasHostUrl() -> String? {
self.canvasHostUrl
@@ -68,15 +69,42 @@ actor BridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
let params = NWParameters.tcp
params.includePeerToPeer = true
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection
@@ -107,7 +135,9 @@ actor BridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
await onConnected?(ok.serverName, self.mainSessionKey)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
@@ -217,6 +247,7 @@ actor BridgeSession {
self.queue = nil
self.buffer = Data()
self.canvasHostUrl = nil
self.mainSessionKey = nil
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
@@ -234,6 +265,10 @@ actor BridgeSession {
self.state = .idle
}
func currentMainSessionKey() -> String? {
self.mainSessionKey
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
@@ -247,6 +282,18 @@ actor BridgeSession {
}
}
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
if let tlsOptions = makeBridgeTLSOptions(tls) {
let tcpOptions = NWProtocolTCP.Options()
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [

View File

@@ -0,0 +1,66 @@
import CryptoKit
import Foundation
import Network
import Security
struct BridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum BridgeTLSStore {
private static let service = "com.clawdbot.bridge.tls"
static func loadFingerprint(stableID: String) -> String? {
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveFingerprint(_ value: String, stableID: String) {
_ = KeychainStore.saveString(value, service: service, account: stableID)
}
}
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeBridgeFingerprint(_ raw: String) -> String {
raw.lowercased().filter { $0.isHexDigit }
}

View File

@@ -190,14 +190,7 @@ actor CameraController {
}
func listDevices() -> [CameraDeviceInfo] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices.map { device in
return Self.discoverVideoDevices().map { device in
CameraDeviceInfo(
id: device.uniqueID,
name: device.localizedName,
@@ -232,7 +225,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
return match
}
}
@@ -252,6 +245,24 @@ actor CameraController {
}
}
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))

View File

@@ -6,7 +6,7 @@ struct ChatSheet: View {
@State private var viewModel: ClawdbotChatViewModel
private let userAccent: Color?
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = State(
initialValue: ClawdbotChatViewModel(

View File

@@ -109,7 +109,7 @@ final class NodeAppModel {
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = "main"
let sessionKey = self.mainSessionKey
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: name,
@@ -205,6 +205,7 @@ final class NodeAppModel {
func connectToBridge(
endpoint: NWEndpoint,
bridgeStableID: String,
tls: BridgeTLSParams?,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
@@ -232,12 +233,16 @@ final class NodeAppModel {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
tls: tls,
onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
await MainActor.run {
self.applyMainSessionKey(mainSessionKey)
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
@@ -286,7 +291,10 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
self.mainSessionKey = "main"
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect()
}
}
@@ -303,10 +311,23 @@ final class NodeAppModel {
self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil
self.seamColorHex = nil
self.mainSessionKey = "main"
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
self.showLocalCanvasOnDisconnect()
}
private func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if SessionKey.isCanonicalMainSessionKey(current) { return }
if trimmed == current { return }
self.mainSessionKey = trimmed
self.talkMode.updateMainSessionKey(trimmed)
}
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
@@ -335,7 +356,10 @@ final class NodeAppModel {
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionKey = mainKey
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
}
} catch {
// ignore

View File

@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{
{ sample, type, error in
let sampleBox = UncheckedSendableBox(value: sample)
// ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts.
recordQueue.async {
let sample = sampleBox.value
if let error {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }

View File

@@ -5,4 +5,11 @@ enum SessionKey {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}

View File

@@ -407,9 +407,11 @@ struct SettingsTab: View {
modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(),
commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
@@ -426,6 +428,7 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
@@ -462,6 +465,8 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
do {
let statusStore = self.connectStatus
@@ -485,6 +490,7 @@ struct SettingsTab: View {
let token = try await BridgeClient().pairAndHello(
endpoint: endpoint,
hello: hello,
tls: tlsParams,
onStatus: { status in
Task { @MainActor in
statusStore.text = status
@@ -500,7 +506,8 @@ struct SettingsTab: View {
self.appModel.connectToBridge(
endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint),
bridgeStableID: stableID,
tls: tlsParams,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
@@ -517,6 +524,47 @@ struct SettingsTab: View {
}
}
private func resolveDiscoveredTLSParams(
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
{
let stableID = bridge.stableID
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
return BridgeTLSParams(
required: true,
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
return BridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return BridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }

View File

@@ -53,6 +53,13 @@ final class TalkModeManager: NSObject {
self.bridge = bridge
}
func updateMainSessionKey(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
self.mainSessionKey = trimmed
}
func setEnabled(_ enabled: Bool) {
self.isEnabled = enabled
if enabled {
@@ -649,7 +656,10 @@ final class TalkModeManager: NSObject {
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
let session = config["session"] as? [String: Any]
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
}
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]

View File

@@ -27,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient {
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String
{
self.lastToken = hello.token
@@ -244,6 +245,8 @@ private func withKeychainValues<T>(
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test"
@@ -292,6 +295,8 @@ private func withKeychainValues<T>(
gatewayPort: 18789,
bridgePort: 18790,
canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B",
@@ -303,6 +308,8 @@ private func withKeychainValues<T>(
gatewayPort: 28789,
bridgePort: 28790,
canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok")

View File

@@ -170,6 +170,10 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false
@@ -292,6 +296,7 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {
@@ -336,6 +341,15 @@ final class AppState {
return host
}
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private func startConfigWatcher() {
let configUrl = ClawdbotConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -401,6 +415,7 @@ final class AppState {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -430,15 +445,46 @@ final class AppState {
changed = true
}
if connectionMode == .remote, let host = remoteHost {
if connectionMode == .remote {
var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
var remoteChanged = false
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
gateway["remote"] = remote
changed = true
}

View File

@@ -282,7 +282,12 @@ actor BridgeConnectionHandler {
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
@@ -298,7 +303,12 @@ actor BridgeConnectionHandler {
case .ok:
self.isAuthenticated = true
do {
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}

View File

@@ -19,6 +19,7 @@ enum ConfigStore {
}
private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
@@ -75,6 +76,7 @@ enum ConfigStore {
method: .configGet,
params: nil,
timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
@@ -83,17 +85,24 @@ enum ConfigStore {
@MainActor
private static func saveToGateway(_ root: [String: Any]) async throws {
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
_ = await self.loadFromGateway()
}
#if DEBUG

View File

@@ -2,6 +2,16 @@ import ClawdbotProtocol
import Foundation
extension ConnectionsStore {
var isTelegramTokenLocked: Bool {
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.tokenSource == "env"
}
var isDiscordTokenLocked: Bool {
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.tokenSource == "env"
}
func loadConfig() async {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
@@ -12,6 +22,7 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
@@ -34,10 +45,23 @@ extension ConnectionsStore {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue
{
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = snap.config?["telegram"]?.dictionaryValue
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -46,7 +70,7 @@ extension ConnectionsStore {
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = snap.config?["discord"]?.dictionaryValue
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -122,7 +146,7 @@ extension ConnectionsStore {
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = snap.config?["signal"]?.dictionaryValue
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -139,7 +163,7 @@ extension ConnectionsStore {
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = snap.config?["imessage"]?.dictionaryValue
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -150,6 +174,15 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
@@ -158,30 +191,24 @@ extension ConnectionsStore {
await self.loadConfig()
}
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.trimmed(self.telegramToken)
if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = self.telegramRequireMention
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setSection("telegram", payload: telegram)
await self.persistConfig()
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
@@ -192,9 +219,9 @@ extension ConnectionsStore {
await self.loadConfig()
}
let discord = self.buildDiscordConfig()
self.setSection("discord", payload: discord)
await self.persistConfig()
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
@@ -205,42 +232,23 @@ extension ConnectionsStore {
await self.loadConfig()
}
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
if self.signalEnabled {
signal.removeValue(forKey: "enabled")
} else {
signal["enabled"] = false
}
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
if self.signalAutoStart {
signal.removeValue(forKey: "autoStart")
} else {
signal["autoStart"] = false
}
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty {
signal.removeValue(forKey: "allowFrom")
} else {
signal["allowFrom"] = allow
}
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setSection("signal", payload: signal)
await self.persistConfig()
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
@@ -251,147 +259,168 @@ extension ConnectionsStore {
await self.loadConfig()
}
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
if self.imessageEnabled {
imessage.removeValue(forKey: "enabled")
} else {
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage.removeValue(forKey: "service")
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
if allow.isEmpty {
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setSection("imessage", payload: imessage)
await self.persistConfig()
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordConfig() -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
if self.discordEnabled {
discord.removeValue(forKey: "enabled")
} else {
discord["enabled"] = false
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
self.setOptionalString(&discord, key: "token", value: self.discordToken)
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord.removeValue(forKey: "dm")
discord["dm"] = NSNull()
}
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord.removeValue(forKey: "replyToMode")
discord["replyToMode"] = replyToMode
}
if let guilds = self.buildDiscordGuildsConfig() {
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord.removeValue(forKey: "guilds")
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord.removeValue(forKey: "actions")
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord.removeValue(forKey: "slashCommand")
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
var dm = base
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
if allow.isEmpty {
dm.removeValue(forKey: "allowFrom")
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsConfig() -> [String: Any]? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = self.trimmed(entry.key)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = self.splitCsv(entry.users)
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
return guilds.isEmpty ? nil : guilds
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
var actions = base
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -410,52 +439,44 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
var slash = base
if self.discordSlashEnabled {
slash["enabled"] = true
} else {
slash.removeValue(forKey: "enabled")
}
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistConfig() async {
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func setSection(_ key: String, payload: [String: Any]) {
if payload.isEmpty {
self.configRoot.removeValue(forKey: key)
} else {
self.configRoot[key] = payload
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
@@ -492,25 +513,29 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
} else if let number = Double(trimmed) {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setOptionalInt(
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
@@ -518,26 +543,39 @@ extension ConnectionsStore {
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
target[key] = number
}
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
if value {
target[key] = true
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target.removeValue(forKey: key)
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
@@ -548,7 +586,7 @@ extension ConnectionsStore {
defaultValue: Bool)
{
if value == defaultValue {
actions.removeValue(forKey: key)
actions[key] = NSNull()
} else {
actions[key] = value
}

View File

@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
let path: String?
let exists: Bool?
let raw: String?
let hash: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
@@ -307,6 +308,7 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview

View File

@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
let remoteCliPathKey = "clawdbot.remoteCliPath"
let canvasEnabledKey = "clawdbot.canvasEnabled"
let cameraEnabledKey = "clawdbot.cameraEnabled"
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
let locationModeKey = "clawdbot.locationMode"
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"

View File

@@ -188,9 +188,15 @@ extension CronJobEditor {
}
}
func applyDeleteAfterRun(to root: inout [String: Any]) {
if self.scheduleKind == .at {
root["deleteAfterRun"] = self.deleteAfterRun
func applyDeleteAfterRun(
to root: inout [String: Any],
scheduleKind: ScheduleKind? = nil,
deleteAfterRun: Bool? = nil
) {
let resolvedSchedule = scheduleKind ?? self.scheduleKind
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
if resolvedSchedule == .at {
root["deleteAfterRun"] = resolvedDelete
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
}

View File

@@ -55,6 +55,7 @@ actor GatewayConnection {
case channelsStatus = "channels.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
@@ -232,7 +233,18 @@ actor GatewayConnection {
func canvasHostUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
(defaults?[key]?.stringValue ?? "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed
}
@@ -267,12 +279,35 @@ actor GatewayConnection {
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
}
for (_, continuation) in self.subscribers {
continuation.yield(push)
}
}
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
@@ -331,6 +366,9 @@ extension GatewayConnection {
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
@@ -361,10 +399,11 @@ extension GatewayConnection {
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(invocation.sessionKey),
"sessionKey": AnyCodable(sessionKey),
"thinking": AnyCodable(invocation.thinking ?? "default"),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
@@ -468,7 +507,8 @@ extension GatewayConnection {
limit: Int? = nil,
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
{
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
@@ -485,8 +525,9 @@ extension GatewayConnection {
attachments: [ClawdbotChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(sessionKey),
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"thinking": AnyCodable(thinking),
"idempotencyKey": AnyCodable(idempotencyKey),
@@ -512,10 +553,11 @@ extension GatewayConnection {
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId)])
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}

View File

@@ -33,14 +33,16 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
@@ -60,7 +62,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -88,13 +91,19 @@ actor GatewayEndpointStore {
return pw
}
}
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -122,6 +131,11 @@ actor GatewayEndpointStore {
return value
}
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -430,9 +444,19 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayBindMode(

View File

@@ -2,77 +2,16 @@ import Foundation
enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private enum GatewayProgramArgumentsError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(message):
message
}
}
}
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
}
private static func gatewayProgramArguments(
port: Int,
bind: String) -> Result<[String], GatewayProgramArgumentsError>
{
let projectRoot = CommandResolver.projectRoot()
#if DEBUG
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
switch CommandResolver.runtimeResolution() {
case let .success(runtime):
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
case .failure:
break
}
}
#endif
let searchPaths = CommandResolver.preferredPaths()
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
{
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
}
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
}
static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
guard let loaded = await self.readDaemonLoaded() else { return false }
return loaded
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
@@ -81,251 +20,44 @@ enum GatewayLaunchAgentManager {
self.logger.info("launchd enable skipped (disable marker set)")
return nil
}
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken()
let desiredPassword = self.preferredGatewayPassword()
let desiredConfig = DesiredConfig(
port: port,
bind: desiredBind,
token: desiredToken,
password: desiredPassword)
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
guard case let .success(programArguments) = programArgumentsResult else {
if case let .failure(error) = programArgumentsResult {
let message = error.localizedDescription
self.logger.error("launchd enable failed: \(message)")
return message
}
return "Failed to resolve gateway command."
}
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
let loaded = await self.isLoaded()
if loaded {
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(programArguments: programArguments)
await self.ensureEnabled()
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
await self.ensureEnabled()
return nil
self.logger.info("launchd enable requested via CLI port=\(port)")
return await self.runDaemonCommand([
"install",
"--force",
"--port",
"\(port)",
"--runtime",
"node",
])
}
self.logger.info("launchd disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
self.logger.info("launchd disable requested via CLI")
return await self.runDaemonCommand(["uninstall"])
}
static func kickstart() async {
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
_ = await self.runDaemonCommand(["restart"], timeout: 20)
}
private static func writePlist(programArguments: [String]) {
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """
<key>CLAWDBOT_GATEWAY_TOKEN</key>
<string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
<string>\(escapedPassword)</string>
"""
}
let argsXml = programArguments
.map { "<string>\(self.escapePlistValue($0))</string>" }
.joined(separator: "\n ")
let plist = """
<?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>Label</key>
<string>\(gatewayLaunchdLabel)</string>
<key>ProgramArguments</key>
<array>
\(argsXml)
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
\(envEntries)
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
</dict>
</plist>
"""
do {
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
} catch {
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
}
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
}
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
static func launchdGatewayLogPath() -> String {
let snapshot = self.launchdConfigSnapshot()
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stdout.isEmpty
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
return stdout
}
return nil
}
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stderr.isEmpty
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private static func preferredGatewayPassword() -> String? {
// First check environment variable
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
// Then check config file (gateway.auth.password)
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func escapePlistValue(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct DesiredConfig: Equatable {
let port: Int
let bind: String
let token: String?
let password: String?
}
private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
let token: String?
let password: String?
func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == desired.port else { return false }
guard (self.bind ?? "loopback") == desired.bind else { return false }
guard self.token == desired.token else { return false }
guard self.password == desired.password else { return false }
return true
}
}
private static func readPlistConfig() -> InstalledConfig? {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
return InstalledConfig(
port: snapshot.port,
bind: snapshot.bind,
token: snapshot.token,
password: snapshot.password)
}
private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd enable failed")
} else {
self.logger.warning("launchd enable failed: \(msg)")
}
}
private static func ensureDisabled() async {
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd disable failed")
} else {
self.logger.warning("launchd disable failed: \(msg)")
return stderr
}
return LogLocator.launchdGatewayLogPath
}
}
@@ -335,20 +67,102 @@ extension GatewayLaunchAgentManager {
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
}
}
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testPreferredGatewayBind() -> String? {
self.preferredGatewayBind()
private static func readDaemonLoaded() async -> Bool? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
timeout: 15,
quiet: true)
guard result.success, let payload = result.payload else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let service = json["service"] as? [String: Any],
let loaded = service["loaded"] as? Bool
else {
return nil
}
return loaded
}
static func _testPreferredGatewayToken() -> String? {
self.preferredGatewayToken()
private struct CommandResult {
let success: Bool
let payload: Data?
let message: String?
}
static func _testEscapePlistValue(_ raw: String) -> String {
self.escapePlistValue(raw)
private struct ParsedDaemonJson {
let text: String
let object: [String: Any]
}
private static func runDaemonCommand(
_ args: [String],
timeout: Double = 15,
quiet: Bool = false) async -> String?
{
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
if result.success { return nil }
return result.message ?? "Gateway daemon command failed"
}
private static func runDaemonCommandResult(
_ args: [String],
timeout: Double,
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "daemon",
extraArgs: self.withJsonFlag(args))
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
let ok = parsed?.object["ok"] as? Bool
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
let payload = parsed?.text.data(using: .utf8)
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
let success = ok ?? response.success
if success {
return CommandResult(success: true, payload: payload, message: nil)
}
if quiet {
return CommandResult(success: false, payload: payload, message: message)
}
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
?? "Gateway daemon command failed (\(exit))"
self.logger.error("\(fullMessage, privacy: .public)")
return CommandResult(success: false, payload: payload, message: detail)
}
private static func withJsonFlag(_ args: [String]) -> [String] {
if args.contains("--json") { return args }
return args + ["--json"]
}
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
return ParsedDaemonJson(text: jsonText, object: object)
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
}
}
#endif

View File

@@ -87,6 +87,14 @@ final class GatewayProcessManager {
self.status = .stopped
return
}
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
switch self.status {
case .starting, .running, .attachedExisting:
return
case .stopped, .failed:
break
}
self.status = .starting
self.logger.debug("gateway start requested")
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
func refreshLog() {
guard self.logRefreshTask == nil else { return }
let path = LogLocator.launchdGatewayLogPath
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) {
@@ -354,7 +362,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared")
}

View File

@@ -83,6 +83,28 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Node Run Commands")
.font(.body)
Picker("", selection: self.$state.systemRunPolicy) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("""
Controls remote command execution on this Mac when it is paired as a node. \
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
"Never" disables `system.run`.
""")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
@@ -92,7 +114,8 @@ struct GeneralSettings: View {
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)

View File

@@ -31,6 +31,8 @@ enum Launchctl {
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String]
let environment: [String: String]
let stdoutPath: String?
let stderrPath: String?
let port: Int?
let bind: String?
@@ -53,6 +55,10 @@ enum LaunchAgentPlist {
guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let stdoutPath = (root["StandardOutPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let stderrPath = (root["StandardErrorPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
@@ -60,6 +66,8 @@ enum LaunchAgentPlist {
return LaunchAgentPlistSnapshot(
programArguments: programArguments,
environment: env,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
port: port,
bind: bind,
token: token,

View File

@@ -1,9 +1,25 @@
import Foundation
enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
private static var logDir: URL {
if let override = ProcessInfo.processInfo.environment["CLAWDBOT_LOG_DIR"], !override.isEmpty {
return URL(fileURLWithPath: override)
}
return URL(fileURLWithPath: "/tmp/clawdbot")
}
private static var stdoutLog: URL {
logDir.appendingPathComponent("clawdbot-stdout.log")
}
private static var gatewayLog: URL {
logDir.appendingPathComponent("clawdbot-gateway.log")
}
private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
}
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
@@ -11,6 +27,7 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default
let files = (try? fm.contentsOfDirectory(
at: self.logDir,
@@ -26,11 +43,13 @@ enum LogLocator {
/// Path to use for launchd stdout/err.
static var launchdLogPath: String {
stdoutLog.path
self.ensureLogDirExists()
return stdoutLog.path
}
/// Path to use for the Gateway launchd job stdout/err.
static var launchdGatewayLogPath: String {
gatewayLog.path
self.ensureLogDirExists()
return gatewayLog.path
}
}

View File

@@ -0,0 +1,81 @@
import Foundation
import OSLog
enum MacNodeConfigFile {
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
static func url() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("mac node config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
}
}
static func systemRunPolicy() -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
let raw = systemRun?["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
systemRun["policy"] = policy.rawValue
root["systemRun"] = systemRun
self.saveDict(root)
}
static func systemRunAllowlist() -> [String]? {
let root = self.loadDict()
let systemRun = root["systemRun"] as? [String: Any]
return systemRun?["allowlist"] as? [String]
}
static func setSystemRunAllowlist(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var root = self.loadDict()
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
}

View File

@@ -31,6 +31,12 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
Binding(
get: { self.state.systemRunPolicy },
set: { self.state.systemRunPolicy = $0 })
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) {
@@ -68,6 +74,13 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera")
}
Picker(selection: self.systemRunPolicyBinding) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
} label: {
Label("Node Run Commands", systemImage: "terminal")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
}
@@ -102,11 +115,14 @@ struct MenuContent: View {
}
if self.state.canvasEnabled {
Button {
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
Task { @MainActor in
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
} label: {
Label(

View File

@@ -104,6 +104,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector {
// MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) {
// Remove any previous injected items.
for item in menu.items where item.tag == self.tag {
@@ -120,13 +122,15 @@ extension MenuSessionsInjector {
if let snapshot = self.cachedSnapshot {
let now = Date()
let mainKey = self.mainSessionKey
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
if row.key == "main", mainKey != "main" { return false }
if row.key == mainKey { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
if lhs.key == mainKey { return true }
if rhs.key == mainKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
@@ -645,7 +649,7 @@ extension MenuSessionsInjector {
compact.representedObject = row.key
menu.addItem(compact)
if row.key != "main", row.key != "global" {
if row.key != self.mainSessionKey, row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self
del.representedObject = row.key

View File

@@ -11,10 +11,39 @@ actor MacNodeBridgePairingClient {
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp)
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
defer { connection.cancel() }
try await AsyncTimeout.withTimeout(
@@ -164,6 +193,18 @@ actor MacNodeBridgePairingClient {
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func startAndWaitForReady(
_ connection: NWConnection,
queue: DispatchQueue) async throws

View File

@@ -36,7 +36,8 @@ actor MacNodeBridgeSession {
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
onConnected: (@Sendable (String) async -> Void)? = nil,
tls: MacNodeBridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
@@ -44,15 +45,35 @@ actor MacNodeBridgeSession {
await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
let params = NWParameters.tcp
params.includePeerToPeer = true
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
params.defaultProtocolStack.transportProtocol = tcpOptions
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: MacNodeBridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
@@ -98,7 +119,8 @@ actor MacNodeBridgeSession {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
@@ -261,6 +283,25 @@ actor MacNodeBridgeSession {
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
params.defaultProtocolStack.transportProtocol = tcpOptions
return params
}
private func failRPC(id: String, error: Error) async {
if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: error)
@@ -419,7 +460,7 @@ actor MacNodeBridgeSession {
do {
try await self.send(response)
} catch {
await self.logInvokeSendFailure(error)
self.logInvokeSendFailure(error)
}
}

View File

@@ -0,0 +1,74 @@
import CryptoKit
import Foundation
import Network
import Security
struct MacNodeBridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum MacNodeBridgeTLSStore {
private static let suiteName = "com.clawdbot.shared"
private static let keyPrefix = "mac.node.bridge.tls."
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static func loadFingerprint(stableID: String) -> String? {
let key = self.keyPrefix + stableID
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil
}
static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID
self.defaults.set(value, forKey: key)
}
}
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
raw.lowercased().filter(\.isHexDigit)
}

View File

@@ -4,6 +4,12 @@ import Foundation
import Network
import OSLog
private struct BridgeTarget {
let endpoint: NWEndpoint
let stableID: String
let tls: MacNodeBridgeTLSParams?
}
@MainActor
final class MacNodeModeCoordinator {
static let shared = MacNodeModeCoordinator()
@@ -37,6 +43,7 @@ final class MacNodeModeCoordinator {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -53,7 +60,16 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
let systemRunPolicy = SystemRunPolicy.load()
if lastSystemRunPolicy == nil {
lastSystemRunPolicy = systemRunPolicy
} else if lastSystemRunPolicy != systemRunPolicy {
lastSystemRunPolicy = systemRunPolicy
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
continue
@@ -63,12 +79,16 @@ final class MacNodeModeCoordinator {
do {
let hello = await self.makeHello()
self.logger.info(
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
try await self.session.connect(
endpoint: endpoint,
endpoint: target.endpoint,
hello: hello,
onConnected: { [weak self] serverName in
tls: target.tls,
onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
},
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
@@ -83,7 +103,7 @@ final class MacNodeModeCoordinator {
return await self.runtime.handleInvoke(req)
})
} catch {
if await self.tryPair(endpoint: endpoint, error: error) {
if await self.tryPair(target: target, error: error) {
continue
}
self.logger.error(
@@ -140,10 +160,14 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.notify.rawValue,
]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue)
@@ -157,7 +181,7 @@ final class MacNodeModeCoordinator {
return commands
}
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
let text = error.localizedDescription.uppercased()
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
@@ -167,9 +191,10 @@ final class MacNodeModeCoordinator {
}
let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: endpoint,
endpoint: target.endpoint,
hello: hello,
silent: shouldSilent,
tls: target.tls,
onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)")
})
@@ -187,7 +212,7 @@ final class MacNodeModeCoordinator {
"mac-\(InstanceIdentity.instanceId)"
}
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
guard let port = Self.loopbackBridgePort(),
let endpointPort = NWEndpoint.Port(rawValue: port)
else {
@@ -195,7 +220,10 @@ final class MacNodeModeCoordinator {
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
return reachable ? endpoint : nil
guard reachable else { return nil }
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
static func loopbackBridgePort() -> UInt16? {
@@ -288,7 +316,7 @@ final class MacNodeModeCoordinator {
})
}
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
do {
@@ -300,7 +328,10 @@ final class MacNodeModeCoordinator {
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
@@ -309,9 +340,23 @@ final class MacNodeModeCoordinator {
}
let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
allowRemoteUrlOverride: false)
preferredLocalPort: preferredLocalPort,
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
@@ -319,7 +364,10 @@ final class MacNodeModeCoordinator {
"mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
return .hostPort(host: "127.0.0.1", port: port)
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
} catch {
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
@@ -330,8 +378,8 @@ final class MacNodeModeCoordinator {
tunnel.terminate()
self.tunnel = nil
}
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return endpoint
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return target
}
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
@@ -351,14 +399,14 @@ final class MacNodeModeCoordinator {
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
}
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
final class DiscoveryState: @unchecked Sendable {
let lock = NSLock()
var resolved = false
var browsers: [NWBrowser] = []
var continuation: CheckedContinuation<NWEndpoint?, Never>?
var continuation: CheckedContinuation<BridgeTarget?, Never>?
func finish(_ endpoint: NWEndpoint?) {
func finish(_ target: BridgeTarget?) {
self.lock.lock()
defer { lock.unlock() }
if self.resolved { return }
@@ -366,7 +414,7 @@ final class MacNodeModeCoordinator {
for browser in self.browsers {
browser.cancel()
}
self.continuation?.resume(returning: endpoint)
self.continuation?.resume(returning: target)
self.continuation = nil
}
}
@@ -392,12 +440,12 @@ final class MacNodeModeCoordinator {
return false
})
{
state.finish(match.endpoint)
state.finish(Self.targetFromResult(match))
return
}
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
state.finish(result.endpoint)
state.finish(Self.targetFromResult(result))
}
}
browser.stateUpdateHandler = { browserState in
@@ -415,6 +463,72 @@ final class MacNodeModeCoordinator {
}
}
}
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
let endpoint = result.endpoint
guard case .service = endpoint else { return nil }
let stableID = BridgeEndpointID.stableID(endpoint)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
let tlsParams = Self.resolveDiscoveredTLSParams(
stableID: stableID,
tlsEnabled: tlsEnabled,
tlsFingerprintSha256: tlsFingerprint)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
private nonisolated static func resolveDiscoveredTLSParams(
stableID: String,
tlsEnabled: Bool,
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
{
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || tlsFingerprintSha256 != nil {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return MacNodeBridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
storeKey: stableID)
}
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : raw
}
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
}
enum MacNodeTokenStore {

View File

@@ -7,6 +7,7 @@ actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -16,6 +17,12 @@ actor MacNodeRuntime {
self.makeMainActorServices = makeMainActorServices
}
func updateMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.mainSessionKey = trimmed
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -48,6 +55,8 @@ actor MacNodeRuntime {
return try await self.handleScreenRecordInvoke(req)
case ClawdbotSystemCommand.run.rawValue:
return try await self.handleSystemRun(req)
case ClawdbotSystemCommand.which.rawValue:
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
default:
@@ -72,28 +81,32 @@ actor MacNodeRuntime {
let placement = params.placement.map {
CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height)
}
let sessionKey = self.mainSessionKey
try await MainActor.run {
_ = try CanvasManager.shared.showDetailed(
sessionKey: "main",
sessionKey: sessionKey,
target: url,
placement: placement)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue:
let sessionKey = self.mainSessionKey
await MainActor.run {
CanvasManager.shared.hide(sessionKey: "main")
CanvasManager.shared.hide(sessionKey: sessionKey)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
try await MainActor.run {
_ = try CanvasManager.shared.show(sessionKey: "main", path: params.url)
_ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let sessionKey = self.mainSessionKey
let result = try await CanvasManager.shared.eval(
sessionKey: "main",
sessionKey: sessionKey,
javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result] as [String: String])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
@@ -109,7 +122,8 @@ actor MacNodeRuntime {
}()
let quality = params?.quality ?? 0.9
let path = try await CanvasManager.shared.snapshot(sessionKey: "main", outPath: nil)
let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else {
@@ -319,7 +333,8 @@ actor MacNodeRuntime {
private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
try await self.ensureA2UIHost()
let json = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let sessionKey = self.mainSessionKey
let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset());
@@ -358,7 +373,8 @@ actor MacNodeRuntime {
}
})()
"""
let resultJSON = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: js)
let sessionKey = self.mainSessionKey
let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
}
@@ -369,8 +385,9 @@ actor MacNodeRuntime {
NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
])
}
let sessionKey = self.mainSessionKey
_ = try await MainActor.run {
try CanvasManager.shared.show(sessionKey: "main", path: a2uiUrl)
try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl)
}
if await self.isA2UIReady(poll: true) { return }
throw NSError(domain: "Canvas", code: 31, userInfo: [
@@ -389,7 +406,8 @@ actor MacNodeRuntime {
let deadline = poll ? Date().addingTimeInterval(6.0) : Date()
while true {
do {
let ready = try await CanvasManager.shared.eval(sessionKey: "main", javaScript: """
let sessionKey = self.mainSessionKey
let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """
(() => String(Boolean(globalThis.clawdbotA2UI)))()
""")
let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -410,6 +428,37 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let wasAllowlisted = SystemRunAllowlist.contains(command)
switch Self.systemRunPolicy() {
case .never:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: policy=never")
case .always:
break
case .ask:
if !wasAllowlisted {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd)
switch decision {
case .allowOnce:
break
case .allowAlways:
SystemRunAllowlist.add(command)
case .deny:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
}
}
}
let env = Self.sanitizedEnv(params.env)
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
@@ -425,7 +474,7 @@ actor MacNodeRuntime {
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: params.env,
env: env,
timeout: timeoutSec)
struct RunPayload: Encodable {
@@ -447,6 +496,33 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
let bins = params.bins
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !bins.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
}
let searchPaths = CommandResolver.preferredPaths()
var matches: [String] = []
var paths: [String: String] = [:]
for bin in bins {
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
matches.append(bin)
paths[bin] = path
}
}
struct WhichPayload: Encodable {
let bins: [String]
let paths: [String: String]
}
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -513,6 +589,39 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
SystemRunPolicy.load()
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
private nonisolated static func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off

View File

@@ -1,7 +1,14 @@
import AppKit
import ClawdbotKit
import CoreLocation
import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
}
@MainActor
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(command)"
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
}

View File

@@ -58,6 +58,13 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return }
if self.shouldSkipWizard() {
self.sessionId = nil
self.currentStep = nil
self.status = "done"
self.errorMessage = nil
return
}
self.isStarting = true
self.errorMessage = nil
self.lastStartMode = mode
@@ -177,6 +184,33 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true
}
private func shouldSkipWizard() -> Bool {
let root = ClawdbotConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
}
struct OnboardingWizardStepView: View {

View File

@@ -351,10 +351,11 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false
case .local:
if !cmd.contains("clawdbot") { return false }
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
if full.contains("gateway-daemon") { return true }
// If args are unavailable, treat a clawdbot listener as expected.
return full == cmd
if cmd.contains("clawdbot"), full == cmd { return true }
return false
case .unconfigured:
return false
}

View File

@@ -0,0 +1,89 @@
import Foundation
enum SystemRunPolicy: String, CaseIterable, Identifiable {
case never
case ask
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .never:
"Never"
case .ask:
"Always Ask"
case .always:
"Always Allow"
}
}
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
if let raw = defaults.string(forKey: systemRunPolicyKey),
let policy = SystemRunPolicy(rawValue: raw)
{
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
let policy: SystemRunPolicy = legacy ? .ask : .never
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
let fallback: SystemRunPolicy = .ask
MacNodeConfigFile.setSystemRunPolicy(fallback)
return fallback
}
}
enum SystemRunAllowlist {
static func key(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
let json = String(data: data, encoding: .utf8)
{
return json
}
return trimmed.joined(separator: " ")
}
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlist(legacy)
return Set(legacy)
}
return []
}
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
let key = key(for: argv)
return self.load(from: defaults).contains(key)
}
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
let key = key(for: argv)
guard !key.isEmpty else { return }
var allowlist = self.load(from: defaults)
if allowlist.insert(key).inserted {
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
}
}
}

View File

@@ -28,9 +28,11 @@ final class WorkActivityStore {
private var currentSessionKey: String?
private var toolSeqBySession: [String: Int] = [:]
private let mainSessionKey = "main"
private var mainSessionKeyStorage = "main"
private let toolResultGrace: TimeInterval = 2.0
var mainSessionKey: String { self.mainSessionKeyStorage }
func handleJob(sessionKey: String, state: String) {
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
if isStart {
@@ -129,6 +131,17 @@ final class WorkActivityStore {
self.refreshDerivedState()
}
func setMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != self.mainSessionKeyStorage else { return }
self.mainSessionKeyStorage = trimmed
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func clearJob(sessionKey: String) {
guard self.jobs[sessionKey] != nil else { return }
self.jobs.removeValue(forKey: sessionKey)
@@ -151,8 +164,8 @@ final class WorkActivityStore {
private func pickNextSession() {
// Prefer main if present.
if self.isActive(sessionKey: self.mainSessionKey) {
self.currentSessionKey = self.mainSessionKey
if self.isActive(sessionKey: self.mainSessionKeyStorage) {
self.currentSessionKey = self.mainSessionKeyStorage
return
}
@@ -163,7 +176,7 @@ final class WorkActivityStore {
}
private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKey ? .main : .other
sessionKey == self.mainSessionKeyStorage ? .main : .other
}
private func isActive(sessionKey: String) -> Bool {

View File

@@ -252,6 +252,7 @@ public struct Snapshot: Codable, Sendable {
public let uptimems: Int
public let configpath: String?
public let statedir: String?
public let sessiondefaults: [String: AnyCodable]?
public init(
presence: [PresenceEntry],
@@ -259,7 +260,8 @@ public struct Snapshot: Codable, Sendable {
stateversion: StateVersion,
uptimems: Int,
configpath: String?,
statedir: String?
statedir: String?,
sessiondefaults: [String: AnyCodable]?
) {
self.presence = presence
self.health = health
@@ -267,6 +269,7 @@ public struct Snapshot: Codable, Sendable {
self.uptimems = uptimems
self.configpath = configpath
self.statedir = statedir
self.sessiondefaults = sessiondefaults
}
private enum CodingKeys: String, CodingKey {
case presence
@@ -275,6 +278,7 @@ public struct Snapshot: Codable, Sendable {
case uptimems = "uptimeMs"
case configpath = "configPath"
case statedir = "stateDir"
case sessiondefaults = "sessionDefaults"
}
}
@@ -845,42 +849,67 @@ public struct ConfigGetParams: Codable, Sendable {
public struct ConfigSetParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigApplyParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigSchemaParams: Codable, Sendable {
}
@@ -1650,6 +1679,27 @@ public struct ChatAbortParams: Codable, Sendable {
}
}
public struct ChatInjectParams: Codable, Sendable {
public let sessionkey: String
public let message: String
public let label: String?
public init(
sessionkey: String,
message: String,
label: String?
) {
self.sessionkey = sessionkey
self.message = message
self.label = label
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case message
case label
}
}
public struct ChatEvent: Codable, Sendable {
public let runid: String
public let sessionkey: String

View File

@@ -1,25 +1,26 @@
import ClawdbotProtocol
import SwiftUI
import Testing
@testable import Clawdbot
@Suite(.serialized)
@MainActor
struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
"self": ["e164": "+15551234567"],
"running": true,
@@ -70,13 +71,13 @@ import Testing
"lastError": "not configured",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])
@@ -93,23 +94,23 @@ import Testing
let view = ConnectionsSettings(store: store)
_ = view.body
}
}
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
@@ -146,13 +147,13 @@ import Testing
"cliPath": "imsg",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])

View File

@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor(
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
onCancel: {},
onSave: { _ in })
view.name = "One-shot"
view.sessionTarget = .main
view.payloadKind = .systemEvent
view.systemEventText = "hello"
view.scheduleKind = .at
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
view.deleteAfterRun = true
let payload = try view.buildPayload()
let raw = payload["deleteAfterRun"]?.value as? Bool
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}
}

View File

@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low",
timeoutSeconds: 15,
deliver: true,
provider: "whatsapp",
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload)

View File

@@ -1,36 +1,69 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite(.serialized)
struct GatewayEndpointStoreTests {
@Test func resolvesLocalHostFromBindModes() {
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "loopback",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "lan",
tailscaleIP: "100.64.0.10") == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil) == "127.0.0.1")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10")
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil) == "127.0.0.1")
@Suite struct GatewayEndpointStoreTests {
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let envToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: ["CLAWDBOT_GATEWAY_TOKEN": "env-token"],
launchdSnapshot: snapshot)
#expect(envToken == "env-token")
let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(fallbackToken == "launchd-token")
}
@Test func resolvesBindModeFromEnvOrConfig() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: [:]) == "tailnet")
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
root: root,
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
@Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
password: nil)
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: nil,
password: "launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
}
}

View File

@@ -2,9 +2,9 @@ import Foundation
import Testing
@testable import Clawdbot
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
"""

View File

@@ -0,0 +1,24 @@
import Darwin
import Foundation
import Testing
@testable import Clawdbot
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager.default
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
setenv("CLAWDBOT_LOG_DIR", logDir.path, 1)
defer {
unsetenv("CLAWDBOT_LOG_DIR")
try? fm.removeItem(at: logDir)
}
_ = LogLocator.launchdGatewayLogPath
var isDir: ObjCBool = false
#expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir))
#expect(isDir.boolValue == true)
}
}

View File

@@ -112,20 +112,6 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address()
}
@Test func gatewayLaunchAgentHelpers() async throws {
await TestIsolation.withEnvValues(
[
"CLAWDBOT_GATEWAY_BIND": "Lan",
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
]) {
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
}
}
@Test func portGuardianParsesListenersAndBuildsReports() {
let output = """
p123

View File

@@ -11,7 +11,8 @@ import Testing
stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123,
configpath: nil,
statedir: nil)
statedir: nil,
sessiondefaults: nil)
let hello = HelloOk(
type: "hello",

View File

@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
let runtime = MacNodeRuntime()
let params = ClawdbotSystemWhichParams(bins: [])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
#expect(response.ok == false)
}
@Test func handleInvokeRejectsEmptyNotification() async throws {
let runtime = MacNodeRuntime()
let params = ClawdbotSystemNotifyParams(title: "", body: "")
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
{
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
.allowOnce
}
}
let services = await MainActor.run { FakeMainActorServices() }

View File

@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
}
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(7))
#expect(order.contains(3))
}
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8))
}
}

View File

@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
thinking: "low",
timeoutSeconds: 30,
deliver: true,
provider: "sms",
channel: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),

View File

@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low")
#expect(opts.deliver == true)
#expect(opts.to == nil)
#expect(opts.provider == .last)
#expect(opts.channel == .last)
}
}

View File

@@ -100,11 +100,18 @@ public struct BridgeHelloOk: Codable, Sendable {
public let type: String
public let serverName: String
public let canvasHostUrl: String?
public let mainSessionKey: String?
public init(type: String = "hello-ok", serverName: String, canvasHostUrl: String? = nil) {
public init(
type: String = "hello-ok",
serverName: String,
canvasHostUrl: String? = nil,
mainSessionKey: String? = nil)
{
self.type = type
self.serverName = serverName
self.canvasHostUrl = canvasHostUrl
self.mainSessionKey = mainSessionKey
}
}

View File

@@ -2,6 +2,7 @@ import Foundation
public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run"
case which = "system.which"
case notify = "system.notify"
}
@@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
}
}
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
public var bins: [String]
public init(bins: [String]) {
self.bins = bins
}
}
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String
public var body: String

View File

@@ -0,0 +1,22 @@
# Clawdbot Chrome Extension (Browser Relay)
Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
## Dev / load unpacked
1. Build/run Clawdbot Gateway with browser control enabled.
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
3. Install the extension to a stable path:
```bash
clawdbot browser extension install
clawdbot browser extension path
```
4. Chrome → `chrome://extensions` → enable “Developer mode”.
5. “Load unpacked” → select the path printed above.
6. Pin the extension. Click the icon on a tab to attach/detach.
## Options
- `Relay port`: defaults to `18792`.

View File

@@ -0,0 +1,438 @@
const DEFAULT_PORT = 18792
const BADGE = {
on: { text: 'ON', color: '#FF5A36' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let debuggerListenersInstalled = false
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
function nowStack() {
try {
return new Error().stack || ''
} catch {
return ''
}
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension`
// Fast preflight: is the relay server up?
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
ws.onclose = () => onRelayClosed('closed')
ws.onerror = () => onRelayClosed('error')
if (!debuggerListenersInstalled) {
debuggerListenersInstalled = true
chrome.debugger.onEvent.addListener(onDebuggerEvent)
chrome.debugger.onDetach.addListener(onDebuggerDetach)
}
})()
try {
await relayConnectPromise
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
for (const tabId of tabs.keys()) {
void chrome.debugger.detach({ tabId }).catch(() => {})
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: disconnected (click to re-attach)',
})
}
tabs.clear()
tabBySession.clear()
childSessionToTab.clear()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
try {
sendToRelay(command)
} catch (err) {
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sessionId = `cb-tab-${nextSession++}`
const attachOrder = nextSession
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// ignore
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
try {
await chrome.debugger.detach({ tabId })
} catch {
// ignore
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay (click to attach/detach)',
})
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: connecting to local relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
// Extra breadcrumbs in chrome://extensions service worker logs.
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message, nowStack())
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
// Map command to tab
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
// No sessionId: pick the first connected tab (stable-ish).
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
return await chrome.debugger.sendCommand(debuggerSession, method, params)
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// ignore
}
}
function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
void detachTab(tabId, reason)
}
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
chrome.runtime.onInstalled.addListener(() => {
// Useful: first-time instructions.
void chrome.runtime.openOptionsPage()
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Clawdbot Browser Relay",
"version": "0.1.0",
"description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Clawdbot Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}

View File

@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clawdbot Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #ff5a36;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.logo img {
width: 28px;
height: 28px;
image-rendering: pixelated;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
width: 160px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">
<img src="icons/icon128.png" alt="" />
</div>
<div>
<h1>Clawdbot Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
</p>
</div>
<div class="card">
<h2>Relay port</h2>
<label for="port">Port</label>
<div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your Clawdbot profile uses a different <code>cdpUrl</code> port.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>

View File

@@ -0,0 +1,59 @@
const DEFAULT_PORT = 18792
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(port) {
const url = `http://127.0.0.1:${port}/`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900)
try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`)
} catch {
setStatus(
'error',
`Relay not reachable at ${url}. Start Clawdbot’s browser relay on this machine, then click the toolbar button again.`,
)
} finally {
clearTimeout(t)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort'])
const port = clampPort(stored.relayPort)
document.getElementById('port').value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
async function save() {
const input = document.getElementById('port')
const port = clampPort(input.value)
await chrome.storage.local.set({ relayPort: port })
input.value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()

View File

@@ -191,7 +191,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or explicitly list channels under `channels.discord.guilds.<id>.channels`.
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -227,6 +227,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
actions: {
reactions: true,
stickers: true,
emojiUploads: true,
stickerUploads: true,
polls: true,
permissions: true,
messages: true,
@@ -237,6 +239,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
roleInfo: true,
roles: false,
channelInfo: true,
channels: true,
voiceStatus: true,
events: true,
moderation: false
@@ -300,11 +303,13 @@ ack reaction after the bot replies.
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
@@ -320,6 +325,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| --- | --- | --- |
| reactions | enabled | React + list reactions + emojiList |
| stickers | enabled | Send stickers |
| emojiUploads | enabled | Upload emojis |
| stickerUploads | enabled | Upload stickers |
| polls | enabled | Create polls |
| permissions | enabled | Channel permission snapshot |
| messages | enabled | Read/send/edit/delete |
@@ -329,6 +336,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| memberInfo | enabled | Member info |
| roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list |
| channels | enabled | Channel/category management |
| voiceStatus | enabled | Voice state lookup |
| events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove |
@@ -351,6 +359,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
Native command notes:
- The registered commands mirror Clawdbot’s chat commands.
@@ -361,6 +370,7 @@ The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`

View File

@@ -189,6 +189,7 @@ Provider options:
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
- `channels.imessage.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.imessage.dms["<handle>"].historyLimit`.
- `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).

View File

@@ -17,7 +17,9 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes

127
docs/channels/matrix.md Normal file
View File

@@ -0,0 +1,127 @@
---
summary: "Matrix support status, capabilities, and configuration"
read_when:
- Working on Matrix channel features
---
# Matrix (plugin)
Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
## Plugin required
Matrix ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/matrix
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/matrix
```
If you choose Matrix during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Install the Matrix plugin:
- From npm: `clawdbot plugins install @clawdbot/matrix`
- From a local checkout: `clawdbot plugins install ./extensions/matrix`
2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact.
Runtime note: Matrix requires Node.js (Bun is not supported).
Minimal config:
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@clawdbot:example.org",
accessToken: "syt_***",
dm: { policy: "pairing" }
}
}
}
```
## Encryption (E2EE)
End-to-end encrypted rooms are **not** supported.
- Use unencrypted rooms or disable encryption when creating the room.
- If a room is E2EE, the bot will receive encrypted events and won’t reply.
## What it is
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
- A Matrix user account owned by the Gateway.
- Deterministic routing: replies go back to Matrix.
- DMs share the agent's main session; rooms map to group sessions.
## Access control (DMs)
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list matrix`
- `clawdbot pairing approve matrix <CODE>`
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
- Allowlist rooms with `channels.matrix.rooms`:
```json5
{
channels: {
matrix: {
rooms: {
"!roomId:example.org": { requireMention: true }
}
}
}
}
```
- `requireMention: false` enables auto-reply in that room.
## Threads
- Reply threading is supported.
- `channels.matrix.replyToMode` controls replies when tagged:
- `off` (default), `first`, `all`
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | ✅ Supported |
| Rooms | ✅ Supported |
| Threads | ✅ Supported |
| Media | ✅ Supported |
| Reactions | ✅ Supported |
| Polls | ✅ Supported |
| Native commands | ✅ Supported |
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.matrix.enabled`: enable/disable channel startup.
- `channels.matrix.homeserver`: homeserver URL.
- `channels.matrix.userId`: Matrix user ID.
- `channels.matrix.accessToken`: access token.
- `channels.matrix.password`: password for login (token stored).
- `channels.matrix.deviceName`: device display name.
- `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.rooms`: per-room settings and allowlist.
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).

Some files were not shown because too many files have changed in this diff Show More