Compare commits

...
Author SHA1 Message Date
Peter Steinberger ad6f69f9af fix: harden env var substitution parsing (#1044) (thanks @sebslight) 2026-01-17 00:25:03 +00:00
Sebastian a4a2c3bc82 feat(config): add env var substitution in config values
Support ${VAR_NAME} syntax in any config string value, substituted at
config load time. Useful for referencing API keys and secrets from
environment variables without hardcoding them in the config file.

- Only uppercase env vars matched: [A-Z_][A-Z0-9_]*
- Missing/empty env vars throw MissingEnvVarError with path context
- Escape with $${VAR} to output literal ${VAR}
- Works with $include (included files also get substitution)

Closes #1009
2026-01-17 00:17:53 +00:00
Peter Steinberger f69298d7eb docs: clarify model key format 2026-01-17 00:15:37 +00:00
Peter Steinberger 6280305899 Merge pull request #1049 from YuriNachos/fix/issue-1020-sessions-perms
fix(sessions): preserve 0600 permissions on sessions.json writes
2026-01-17 00:07:49 +00:00
Peter Steinberger 543d1ea3c1 docs: fix changelog after 2026.1.15 release 2026-01-17 00:03:56 +00:00
Peter Steinberger 1569db1754 style: format with oxfmt 2026-01-17 00:03:00 +00:00
Peter Steinberger c54c665f97 feat: enhance web_fetch fallbacks 2026-01-17 00:00:49 +00:00
Peter SteinbergerandYurii Chukhlib a84000c6d9 docs(changelog): note PR #1050 fixes
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Peter SteinbergerandYurii Chukhlib a979a62f8e fix(openai-image-gen): handle url and b64_json responses
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Peter SteinbergerandYurii Chukhlib 13e2dd97a7 fix(session): preserve overrides on /new reset
Co-authored-by: Yurii Chukhlib <yuri.v.chu@gmail.com>
2026-01-16 23:59:04 +00:00
Yurii Chukhlib 6bba84b043 fix(channels): include linked field in WhatsApp describeAccount
Fixes #1030

The describeAccount function for WhatsApp was not returning the
linked field, causing the Channels status section to show
"Not linked" even when WhatsApp was properly linked and working.

The fix adds the linked field to describeAccount, set to the same
value as configured (Boolean(account.authDir)). This ensures that
the Channels section and Health section show consistent status.
2026-01-16 23:59:04 +00:00
Peter Steinberger e31251293b fix: scope history injection to pending-only 2026-01-16 23:52:42 +00:00
Peter Steinberger 56ed5cc2d9 fix: prefer config over env for matrix creds 2026-01-16 23:24:18 +00:00
Peter Steinberger af31e0d969 docs: redirect /install/node to install section 2026-01-16 23:18:50 +00:00
Peter Steinberger 37fa4f7eef feat: improve web_fetch readability extraction 2026-01-16 23:18:01 +00:00
Peter Steinberger 9aad6dfe1b Merge pull request #1046 from YuriNachos/feature/web-search-localization
feat(web-search): add country and language parameters
2026-01-16 23:17:46 +00:00
Peter Steinberger 6b8db36a15 docs: clarify Brave web_search defaults (#1046) (thanks @YuriNachos) 2026-01-16 23:16:59 +00:00
Yurii ChukhlibandClaude 171060541a docs(web-search): document country and language parameters
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii ChukhlibandClaude 003547c818 test(web-search): add tests for country and language parameters
Added three new test cases to verify the new country, search_lang, and ui_lang
parameters are correctly passed to the Brave Search API.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 23:14:33 +00:00
Yurii Chukhlib c4e1064066 feat(web-search): pass country and language params to Brave API
- Add country, search_lang, and ui_lang optional parameters to runWebSearch
- Pass new parameters to Brave Search API as URL query parameters
- Update cache key to include localization parameters
- Wire parameters through createWebSearchTool execute function
2026-01-16 23:14:33 +00:00
Yurii Chukhlib d163dbcfcd feat(web-search): add country and language optional parameters to schema 2026-01-16 23:14:33 +00:00
Yurii Chukhlib 2acfeb1096 fix(openai-image-gen): remove deprecated response_format, use URL download 2026-01-16 23:14:33 +00:00
Yurii Chukhlib b3b6d421cc fix(session): reset compactionCount on /new and /reset 2026-01-16 23:14:33 +00:00
Yurii Chukhlib cf72b9db3c fix(sessions): preserve 0600 permissions on sessions.json writes 2026-01-16 23:14:33 +00:00
Peter Steinberger 106e308953 fix: prefer config tokens over env for discord/telegram 2026-01-16 23:13:00 +00:00
Peter Steinberger bf72a126d1 docs: add /help hub and Node/npm PATH guide 2026-01-16 23:10:29 +00:00
Peter Steinberger 28a5d124c3 fix: stabilize transport-ready test timing 2026-01-16 23:03:12 +00:00
Peter Steinberger b4045e6adb fix: remove stale pnpm patch entry 2026-01-16 22:56:08 +00:00
Peter Steinberger a7bec3340f fix: drop unsigned gemini tool calls from history 2026-01-16 22:43:16 +00:00
Peter Steinberger a4e99ecdaf chore: remove patch references 2026-01-16 22:41:57 +00:00
Peter Steinberger dcd20d564f docs: expand directory CLI guide 2026-01-16 22:40:36 +00:00
Peter Steinberger 59f6ea9b21 feat: directory for plugin channels 2026-01-16 22:40:36 +00:00
Peter Steinberger e44f28bd4f feat: unify directory across channels 2026-01-16 22:40:36 +00:00
Peter Steinberger 929b86e302 feat(ui): delete sessions from Control UI 2026-01-16 22:33:47 +00:00
Peter Steinberger 76d3d58b5c chore: oxfmt 2026-01-16 22:33:47 +00:00
Peter Steinberger 548a32c8d4 chore: drop unused patches 2026-01-16 22:31:21 +00:00
Peter Steinberger 500c75b4f0 fix: align ZAI thinking toggles 2026-01-16 22:26:43 +00:00
Peter Steinberger 3567dc4a47 fix: hard-stop sessions.delete cleanup 2026-01-16 22:24:13 +00:00
Peter Steinberger 7df37c2dbd fix: override tar to 7.5.3 2026-01-16 22:07:34 +00:00
Peter Steinberger 28a4cbc4ef docs: mention stopping sub-agents 2026-01-16 22:05:13 +00:00
Peter Steinberger 21fe4d9ded fix: bump tar to 7.5.3 2026-01-16 21:58:32 +00:00
Peter Steinberger 05d149a49b fix: treat reply-to-bot as implicit mention across channels 2026-01-16 21:51:01 +00:00
Peter Steinberger 97a41a6509 fix: suppress zero sub-agent stop note 2026-01-16 21:41:55 +00:00
Peter Steinberger a0be85c34c fix: /stop aborts subagents 2026-01-16 21:37:22 +00:00
tsu 390bd11f33 feat: add zalouser channel + directory CLI (#1032) (thanks @suminhthanh)
- Unified UX: channels login + message send; no plugin-specific top-level command\n- Added generic directory CLI for channel identity/groups\n- Docs: channel + plugin pages
2026-01-16 21:28:18 +00:00
Peter Steinberger 16768a9998 fix: start fresh cron sessions each run 2026-01-16 21:27:56 +00:00
e9d6869290 Telegram: Add reply-chain detection to bypass mention requirement (#1038)
* Telegram: add reply-chain detection to bypass mention requirement

* fix: allow telegram reply-chain mention bypass (#1038) (thanks @adityashaw2)

---------

Co-authored-by: Aditya Shaw <aditya@adityashaw.dev>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 21:24:45 +00:00
Peter Steinberger 9072e35f08 fix: hard-abort clears queues on /stop 2026-01-16 21:15:25 +00:00
Peter Steinberger d887027e95 style: run oxfmt 2026-01-16 21:11:55 +00:00
Peter Steinberger 168a0f4998 Merge pull request #1016 from timolins/main
Models: add Vercel AI Gateway auth
2026-01-16 21:06:56 +00:00
Peter Steinberger f3a37664d5 fix: tighten Vercel AI Gateway onboarding docs/tests (#1016) (thanks @timolins) 2026-01-16 21:06:21 +00:00
Peter Steinberger 8bcbe68637 chore: sync Peekaboo submodule on install 2026-01-16 21:04:58 +00:00
Timo Lins beb9eac5f7 Models: add Vercel AI Gateway auth 2026-01-16 21:00:15 +00:00
Peter Steinberger 0dcffcd5b0 fix: repair orphaned user turns before embedded prompts 2026-01-16 20:52:18 +00:00
Peter Steinberger 56efbce31e feat: enable telegram reaction notifications by default 2026-01-16 20:51:42 +00:00
Peter Steinberger e7c42884fc test: cover transport readiness waits 2026-01-16 20:48:43 +00:00
Peter Steinberger 08c0405f0f fix: quiet skill command normalization logs 2026-01-16 20:37:23 +00:00
Peter Steinberger 470add877c feat: default telegram reaction level minimal 2026-01-16 20:35:47 +00:00
Peter SteinbergerandSzpadel aaa310c047 fix: bound signal/imessage transport readiness waits
Co-authored-by: Szpadel <1857251+Szpadel@users.noreply.github.com>
2026-01-16 20:33:04 +00:00
RubyandShadow 0cd24137e8 feat: add session.identityLinks for cross-platform DM session linking (#1033)
Co-authored-by: Shadow <shadow@clawd.bot>
2026-01-16 14:23:22 -06:00
Peter Steinberger 8ffb8cc363 Merge pull request #1013 from marcmarg/fix/format-parameter-and-subagent-auth
Fix format parameter conflict and subagent auth inheritance
2026-01-16 20:20:40 +00:00
Peter Steinberger 7a9854cb06 chore: update clawtributors 2026-01-16 20:20:26 +00:00
Marc 5ee4456c6e fix: merge subagent auth profiles 2026-01-16 20:20:26 +00:00
Marcandmarcmarg de31583021 fix: avoid format keyword in tool schemas
Co-authored-by: marcmarg <marcmarg@users.noreply.github.com>
2026-01-16 20:20:26 +00:00
Peter Steinberger 38b49aa0f6 feat: expand skill command registration 2026-01-16 20:17:32 +00:00
Peter Steinberger 69761e8a51 feat: scope telegram inline buttons 2026-01-16 20:16:41 +00:00
Peter SteinbergerandChristoph Nakazawa 3431d3d115 chore: tweak tool call narration guidance (#1008)
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-16 19:56:04 +00:00
Peter Steinberger fe9e027d58 test: deflake background exec timeout 2026-01-16 19:48:52 +00:00
Peter Steinberger 33d17957e5 test: cover doctor launchctl env overrides (#1037)
* test: cover doctor launchctl env overrides

* style(macos): fix swiftformat lint
2026-01-16 19:40:45 +00:00
Nima KarimiandPeter Steinberger 25ae5f897e fix(macos): check config file mode for gateway token/password resolution (#1022)
* fix: honor config gateway mode for credentials

* chore: oxfmt doctor platform notes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 19:29:48 +00:00
Peter Steinberger 624ff09314 test: expand gateway auth probe coverage 2026-01-16 19:16:03 +00:00
Peter Steinberger c8003ae472 docs: update changelog for gateway probe fix (#1011) (thanks @ivanrvpereira) 2026-01-16 19:07:44 +00:00
Peter Steinberger 6bf627bce8 Merge pull request #1011 from ivanrvpereira/fix/security-audit-gateway-auth
fix(security): resolve local auth for gateway probe
2026-01-16 19:05:07 +00:00
henrymascot 08525435e0 showcase: add Linear CLI and Beeper CLI
- @NessZerra Linear CLI - manage Linear issues from terminal, works with Claude Code/Clawdbot
- @jules Beeper CLI - read/send/archive messages via Beeper Desktop MCP API
2026-01-16 19:05:02 +00:00
Yurii Chukhlib 9e39a56033 fix(sessions): preserve 0600 permissions on sessions.json writes 2026-01-16 19:44:14 +01:00
Shadow 026cf1130e Changelog: note Discord skill truncation 2026-01-16 10:03:53 -06:00
Wilkins bb14b19922 fix: truncate skill command descriptions to 100 chars for Discord (#1018)
* fix: truncate skill command descriptions to 100 chars for Discord

Discord slash commands have a 100 character limit for descriptions.
Skill descriptions were not being truncated, causing command registration
to fail with an empty error from the Discord API.

* style: format

* style: format
2026-01-16 10:01:59 -06:00
MarcandClaude Opus 4.5 78279fb758 fix: inherit auth-profiles from main agent for subagents
When a subagent is spawned, it creates a new agent directory but has no
auth-profiles.json. This adds a fallback in ensureAuthProfileStore() to
inherit auth-profiles from the main agent when the subagent has none,
ensuring subagents can use OAuth tokens without manual file copying.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:51:50 +01:00
MarcandClaude Opus 4.5 38cf90f6db fix: rename format parameter to avoid JSON Schema keyword conflict
- Rename `format` to `snapshotFormat` in browser-tool schema and implementation
- Rename `format` to `outputFormat` in canvas-tool schema and implementation
- The parameter name `format` conflicts with JSON Schema keyword, causing
  Google Cloud Code Assist to reject the schema as invalid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:50:02 +01:00
Ivan Pereira 544ca062a3 test(security): add coverage for gateway probe auth selection 2026-01-16 13:31:01 +00:00
Ivan Pereira be9aa5494a fix(security): resolve local auth for gateway probe 2026-01-16 13:19:55 +00:00
Peter Steinberger 0d6af15d1c feat: add user-invocable skill commands 2026-01-16 12:10:29 +00:00
Peter Steinberger eda9410bce fix: stabilize docker test suite 2026-01-16 11:47:14 +00:00
Peter Steinberger a51ed8a5dd fix(cli): auto-update global installs 2026-01-16 11:45:37 +00:00
Peter Steinberger 19bcbf85df fix: scope whatsapp self-chat response prefix 2026-01-16 10:54:11 +00:00
Peter Steinberger f49d0e5476 fix: expand exec abort/timeout coverage 2026-01-16 10:43:22 +00:00
Peter Steinberger 9c4c9c5edd chore: release 2026.1.15 2026-01-16 10:37:30 +00:00
0f34255359 chore: ignore local identity files (#1001) (thanks @gerardward2007)
* chore: ignore local identity files (IDENTITY.md, USER.md)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: ignore local identity files (#1001) (thanks @gerardward2007)

* chore: format session status tool

* chore: format bash exec background abort test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 10:30:04 +00:00
tosh-hamburg de5fb65cb8 fix: docker-setup fails on Synology because of problem with bun (#1002) 2026-01-16 10:03:56 +00:00
Roshan Singh e773f84e39 fix: keep background exec aborts from killing sessions (#1000) (thanks @roshanasingh4)
When exec returns early in background mode, the tool-call AbortSignal can fire and previously caused killProcessTree(SIGKILL). Ignore abort after yielding/backgrounding so background sessions keep running.
2026-01-16 10:01:39 +00:00
Peter Steinberger b969c216fc docs: refresh unreleased highlights 2026-01-16 09:51:27 +00:00
Peter Steinberger f51a4d2aca docs: sort unreleased changelog 2026-01-16 09:46:12 +00:00
Peter Steinberger 30b3a9de30 fix: drop oauth status from session status 2026-01-16 09:39:12 +00:00
Peter Steinberger 0391f6553b fix: correct minimax usage + show reset 2026-01-16 09:36:45 +00:00
Peter Steinberger d0c986c4f0 feat: warn on weak model tiers 2026-01-16 09:34:37 +00:00
Peter Steinberger 384028e12e refactor: unify session reset helper 2026-01-16 09:33:39 +00:00
Peter Steinberger 4c14d6c8db chore: format web monitor inbox tests 2026-01-16 09:26:18 +00:00
Peter Steinberger 6fa437613b fix: delete transcripts on role ordering reset 2026-01-16 09:20:16 +00:00
Peter Steinberger 949fa1051f fix: wire markdown variant renderer 2026-01-16 09:19:25 +00:00
Peter Steinberger 4965727f39 chore: run format and fix sandbox browser timeouts 2026-01-16 09:18:58 +00:00
Peter Steinberger 7c34883267 refactor: consolidate chat markdown rendering 2026-01-16 09:16:43 +00:00
Peter Steinberger 072c3dc55c fix: suppress WhatsApp pairing replies for historical DMs 2026-01-16 09:10:44 +00:00
Peter Steinberger 83b3875131 docs: add control UI unauthorized FAQ hint 2026-01-16 09:07:20 +00:00
Peter Steinberger a35083808c chore: update macOS package lock 2026-01-16 09:06:23 +00:00
Peter Steinberger 3d3ec9d972 docs: add browserless remote CDP example 2026-01-16 09:05:30 +00:00
Peter Steinberger 9838a2850f fix: reset sessions after role ordering conflicts 2026-01-16 09:04:04 +00:00
Peter Steinberger 6c6bc6ff1c fix: add control UI auth guidance 2026-01-16 09:03:02 +00:00
Peter Steinberger 1791c1a765 feat: render native chat markdown via Textual 2026-01-16 09:02:27 +00:00
Peter Steinberger 6e53c061ff fix: tune remote CDP timeouts 2026-01-16 09:01:25 +00:00
Peter Steinberger 1773f8aea2 fix: upgrade ws to wss for https CDP 2026-01-16 08:44:08 +00:00
Peter Steinberger 52bdf57743 Merge pull request #880 from mkbehr/openai-image-gen-fix
fix: Make openai-image-gen skill use appropriate defaults per model.
2026-01-16 08:42:13 +00:00
Peter Steinberger b3ab24eb8e fix: align image-gen defaults (#880) (thanks @mkbehr) 2026-01-16 08:41:23 +00:00
Michael BehrandClaude Opus 4.5 6ac1c1d6ea fix(openai-image-gen): use correct file extension for output format
When --output-format is specified for GPT models, save files with
the correct extension (.webp, .jpeg, or .png) instead of always
using .png.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:37:57 +00:00
Michael BehrandClaude Opus 4.5 7655a501d0 feat(openai-image-gen): add model-specific parameter support
- Auto-detect model and apply appropriate defaults for size/quality
- Add --background, --output-format, and --style parameters
- Enforce dall-e-3 count=1 limitation with automatic adjustment
- Omit quality parameter for dall-e-2 (not supported)
- Document model-specific parameters and supported values

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:37:57 +00:00
Peter Steinberger 3b1b14b0b1 Merge pull request #895 from mukhtharcm/feat/chrome-browser-improvements
feat(browser): add support for authenticated remote browser profiles
2026-01-16 08:32:02 +00:00
Peter Steinberger bf15c87d2b fix: support authenticated remote CDP URLs (#895) (thanks @mukhtharcm) 2026-01-16 08:31:51 +00:00
Peter Steinberger 2dae4d382f Merge pull request #860 from nachoiacovino/feat/telegram-custom-commands
feat(telegram): support custom commands in config
2026-01-16 08:26:26 +00:00
Peter Steinberger e9a47a02d1 fix: stabilize macOS audio test and default browser detection 2026-01-16 08:25:39 +00:00
Peter SteinbergerandNacho Iacovino 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 CMandPeter Steinberger 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
Muhammed Mukhthar CM 8e80823b03 test(browser): fix missing getHeadersWithAuth mock in server tests 2026-01-16 08:11:00 +00:00
Muhammed Mukhthar CM 319afd192d feat(browser): add support for remote playwright websocket auth 2026-01-16 08:11:00 +00:00
Muhammed Mukhthar CM 6e0daf0936 feat(browser): add support for authenticated remote CDP profiles 2026-01-16 08:10:32 +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 OswalandPeter Steinberger 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 OpenshawandClaude 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
UbuntuandAlphonse-arianee 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
LongandAmp 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 SteinbergerandChristoph Nakazawa 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 SteinbergerandDrake Thomsen 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 HoltandClaude Opus 4.5 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 SteinbergerandJohn Young 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 SteinbergerandMax 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 RealandClaude Opus 4.5 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
voidandvoid 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 SteinbergerandTyler Yust 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 SteinbergerandTyler Yust 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 SteinbergerandTyler Yust 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
juanpablodlcandPeter Steinberger 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
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
Roshan Singh d0455f2683 fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 11:43:22 +00:00
740 changed files with 29239 additions and 4738 deletions

No files matched your search

+2
View File
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
# Local untracked files # Local untracked files
.local/ .local/
.vscode/ .vscode/
IDENTITY.md
USER.md
+12
View File
@@ -60,6 +60,9 @@
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. - 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. - 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) ### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. - **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! - **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!
@@ -104,6 +107,7 @@
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. - Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
@@ -113,6 +117,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`. - 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. - 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 ## 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: The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
+147 -96
View File
@@ -1,8 +1,117 @@
# Changelog # Changelog
## 2026.1.15 (unreleased) ## 2026.1.16 (unreleased)
- TBD ### Highlights
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
### Breaking
- **BREAKING:** Discord/Telegram channel tokens now prefer config over env (env is fallback only).
- **BREAKING:** Matrix channel credentials now prefer config over env (env is fallback only).
### Changes
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
### Fixes
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts.
- Channels: treat replies to the bot as implicit mentions across supported channels.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
- CLI: auto-update global installs when installed via a package manager.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles.
## 2026.1.15
### Highlights
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
### Changes
- 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).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- 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.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- 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).
- 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.
- 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: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
- 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.
- 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.
### Fixes
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
- 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.
- 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.
- 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.
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- 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.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
## 2026.1.14-1 ## 2026.1.14-1
@@ -13,98 +122,56 @@
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. - Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
### Changes ### Changes
#### Web Tools
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
#### Browser / Control UI
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- 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: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
#### Security
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
#### Onboarding / Daemon
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
#### Auth / Usage / Config
- Usage: add MiniMax coding plan usage tracking.
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
#### Channels
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
#### Docs
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - 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. - 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: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) - Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`. - Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- Docs: add multi-gateway guide (sidebar + nav). - 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 ### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
#### Gateway / Daemon / Sessions
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance.
- 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.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- 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.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - 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: 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. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - 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. - 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. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani. - Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani. - Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - 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).
#### CLI / Onboarding ## 2026.1.14
- Onboarding: show web search setup at the end (not the beginning).
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
- Health: colorize “not configured” provider lines for easier scanning.
#### Control UI / TUI ### Changes
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866) - Usage: add MiniMax coding plan usage tracking.
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor. - Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard). - Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
- Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
### Fixes
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- 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: 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: 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)`. - 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 / Auth / Tools / Sandbox
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. - Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
@@ -123,34 +190,17 @@
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. - 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: 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: 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. - 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. - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
- macOS: update cron testing channel arg. (#896) — thanks @ngutman.
#### Channels / Messaging
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. - Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935) - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. - WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
- WhatsApp: harden owner command auth.
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
#### Config / Doctor / Packaging
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
## 2026.1.13 ## 2026.1.13
@@ -163,6 +213,7 @@
### Fixes ### Fixes
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). - 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: 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 ## 2026.1.12-1
+2
View File
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install RUN pnpm ui:install
RUN pnpm ui:build RUN pnpm ui:build
Submodule Peekaboo updated: 95ad7532c1...5c195f5e46.
+20 -18
View File
@@ -474,22 +474,24 @@ Core contributors:
Thanks to all clawtributors: Thanks to all clawtributors:
<p align="left"> <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/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/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></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/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></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/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/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/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></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/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/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=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></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/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=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/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/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/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/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/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/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> <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/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></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/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/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/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> <a href="https://github.com/Szpadel"><img src="https://avatars.githubusercontent.com/u/1857251?v=4&s=48" width="48" height="48" alt="Szpadel" title="Szpadel"/></a>
</p> </p>
+81 -17
View File
@@ -2,6 +2,87 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5998</sparkle:version>
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
<li>TUI: show provider/model labels for the active session and default model.</li>
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</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: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
<item> <item>
<title>2026.1.14-1</title> <title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate> <pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
@@ -190,22 +271,5 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item> </item>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
<h3>Fixes</h3>
<ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
</channel> </channel>
</rss> </rss>
+1 -1
View File
@@ -119,7 +119,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7") testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16") 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 { tasks.withType<Test>().configureEach {
@@ -16,6 +16,7 @@ import com.clawdbot.android.bridge.BridgeDiscovery
import com.clawdbot.android.bridge.BridgeEndpoint import com.clawdbot.android.bridge.BridgeEndpoint
import com.clawdbot.android.bridge.BridgePairingClient import com.clawdbot.android.bridge.BridgePairingClient
import com.clawdbot.android.bridge.BridgeSession import com.clawdbot.android.bridge.BridgeSession
import com.clawdbot.android.bridge.BridgeTlsParams
import com.clawdbot.android.node.CameraCaptureManager import com.clawdbot.android.node.CameraCaptureManager
import com.clawdbot.android.node.LocationCaptureManager import com.clawdbot.android.node.LocationCaptureManager
import com.clawdbot.android.BuildConfig import com.clawdbot.android.BuildConfig
@@ -160,6 +161,9 @@ class NodeRuntime(context: Context) {
onInvoke = { req -> onInvoke = { req ->
handleInvoke(req.command, req.paramsJson) handleInvoke(req.command, req.paramsJson)
}, },
onTlsFingerprint = { stableId, fingerprint ->
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
},
) )
private val chat = ChatController(scope = scope, session = session, json = json) private val chat = ChatController(scope = scope, session = session, json = json)
@@ -488,12 +492,17 @@ class NodeRuntime(context: Context) {
scope.launch { scope.launch {
_statusText.value = "Connecting…" _statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken() val storedToken = prefs.loadBridgeToken()
val tls = resolveTlsParams(endpoint)
val resolved = val resolved =
if (storedToken.isNullOrBlank()) { if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…" _statusText.value = "Pairing…"
BridgePairingClient().pairAndHello( BridgePairingClient().pairAndHello(
endpoint = endpoint, endpoint = endpoint,
hello = buildPairingHello(token = null), hello = buildPairingHello(token = null),
tls = tls,
onTlsFingerprint = { fingerprint ->
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
},
) )
} else { } else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim()) BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
@@ -510,6 +519,7 @@ class NodeRuntime(context: Context) {
session.connect( session.connect(
endpoint = endpoint, endpoint = endpoint,
hello = buildSessionHello(token = authToken), hello = buildSessionHello(token = authToken),
tls = tls,
) )
} }
} }
@@ -556,6 +566,41 @@ class NodeRuntime(context: Context) {
session.disconnect() 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) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
scope.launch { scope.launch {
val trimmed = payloadJson.trim() val trimmed = payloadJson.trim()
@@ -147,6 +147,16 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, token.trim()) } 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 { private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString("node.instanceId", null)?.trim() val existing = prefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing if (!existing.isNullOrBlank()) return existing
@@ -143,6 +143,8 @@ class BridgeDiscovery(
val gatewayPort = txtInt(resolved, "gatewayPort") val gatewayPort = txtInt(resolved, "gatewayPort")
val bridgePort = txtInt(resolved, "bridgePort") val bridgePort = txtInt(resolved, "bridgePort")
val canvasPort = txtInt(resolved, "canvasPort") val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "bridgeTls")
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
val id = stableId(serviceName, "local.") val id = stableId(serviceName, "local.")
localById[id] = localById[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -155,6 +157,8 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
publish() publish()
} }
@@ -209,6 +213,11 @@ class BridgeDiscovery(
return txt(info, key)?.toIntOrNull() 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) { private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}${domain}" val ptrName = "${serviceType}${domain}"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
@@ -252,6 +261,8 @@ class BridgeDiscovery(
val gatewayPort = txtIntValue(txt, "gatewayPort") val gatewayPort = txtIntValue(txt, "gatewayPort")
val bridgePort = txtIntValue(txt, "bridgePort") val bridgePort = txtIntValue(txt, "bridgePort")
val canvasPort = txtIntValue(txt, "canvasPort") val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
val id = stableId(instanceName, domain) val id = stableId(instanceName, domain)
next[id] = next[id] =
BridgeEndpoint( BridgeEndpoint(
@@ -264,6 +275,8 @@ class BridgeDiscovery(
gatewayPort = gatewayPort, gatewayPort = gatewayPort,
bridgePort = bridgePort, bridgePort = bridgePort,
canvasPort = canvasPort, canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
) )
} }
@@ -474,6 +487,11 @@ class BridgeDiscovery(
return txtValue(records, key)?.toIntOrNull() 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 { private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. // 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. // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
@@ -10,6 +10,8 @@ data class BridgeEndpoint(
val gatewayPort: Int? = null, val gatewayPort: Int? = null,
val bridgePort: Int? = null, val bridgePort: Int? = null,
val canvasPort: Int? = null, val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) { ) {
companion object { companion object {
fun manual(host: String, port: Int): BridgeEndpoint = fun manual(host: String, port: Int): BridgeEndpoint =
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
name = "$host:$port", name = "$host:$port",
host = host, host = host,
port = port, port = port,
tlsEnabled = false,
tlsFingerprintSha256 = null,
) )
} }
} }
@@ -14,7 +14,6 @@ import java.io.BufferedWriter
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket
class BridgePairingClient { class BridgePairingClient {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -33,13 +32,38 @@ class BridgePairingClient {
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null) 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) { withContext(Dispatchers.IO) {
val socket = Socket() if (tls != null) {
try {
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 socket.tcpNoDelay = true
try { try {
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 60_000 socket.soTimeout = 60_000
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
@@ -68,14 +92,14 @@ class BridgePairingClient {
) )
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull() val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response") ?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
when (firstObj["type"].asStringOrNull()) { return when (firstObj["type"].asStringOrNull()) {
"hello-ok" -> PairResult(ok = true, token = hello.token) "hello-ok" -> PairResult(ok = true, token = hello.token)
"error" -> { "error" -> {
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE" val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
val message = firstObj["message"].asStringOrNull() ?: "pairing required" val message = firstObj["message"].asStringOrNull() ?: "pairing required"
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") { if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
return@withContext PairResult(ok = false, token = null, error = "$code: $message") return PairResult(ok = false, token = null, error = "$code: $message")
} }
sendJson( sendJson(
@@ -98,12 +122,12 @@ class BridgePairingClient {
when (next["type"].asStringOrNull()) { when (next["type"].asStringOrNull()) {
"pair-ok" -> { "pair-ok" -> {
val token = next["token"].asStringOrNull() val token = next["token"].asStringOrNull()
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token) return PairResult(ok = !token.isNullOrBlank(), token = token)
} }
"error" -> { "error" -> {
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE" val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
val m = next["message"].asStringOrNull() ?: "pairing failed" val m = next["message"].asStringOrNull() ?: "pairing failed"
return@withContext PairResult(ok = false, token = null, error = "$c: $m") return PairResult(ok = false, token = null, error = "$c: $m")
} }
} }
} }
@@ -113,7 +137,7 @@ class BridgePairingClient {
} }
} catch (e: Exception) { } catch (e: Exception) {
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" } val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
PairResult(ok = false, token = null, error = message) return PairResult(ok = false, token = null, error = message)
} finally { } finally {
try { try {
socket.close() socket.close()
@@ -35,6 +35,7 @@ class BridgeSession(
private val onDisconnected: (message: String) -> Unit, private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: suspend (InvokeRequest) -> InvokeResult, private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) { ) {
data class Hello( data class Hello(
val nodeId: String, val nodeId: String,
@@ -66,11 +67,17 @@ class BridgeSession(
@Volatile private var canvasHostUrl: String? = null @Volatile private var canvasHostUrl: String? = null
@Volatile private var mainSessionKey: 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 private var job: Job? = null
fun connect(endpoint: BridgeEndpoint, hello: Hello) { fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
desired = endpoint to hello desired = DesiredConnection(endpoint, hello, tls)
if (job == null) { if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() } job = scope.launch(Dispatchers.IO) { runLoop() }
} }
@@ -78,7 +85,7 @@ class BridgeSession(
suspend fun updateHello(hello: Hello) { suspend fun updateHello(hello: Hello) {
val target = desired ?: return val target = desired ?: return
desired = target.first to hello desired = target.copy(hello = hello)
val conn = currentConnection ?: return val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello)) conn.sendJson(buildHelloJson(hello))
} }
@@ -165,10 +172,10 @@ class BridgeSession(
continue continue
} }
val (endpoint, hello) = target val (endpoint, hello, tls) = target
try { try {
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
connectOnce(endpoint, hello) connectOnce(endpoint, hello, tls)
attempt = 0 attempt = 0
} catch (err: Throwable) { } catch (err: Throwable) {
attempt += 1 attempt += 1
@@ -192,12 +199,28 @@ class BridgeSession(
return InvokeResult.error(code = "UNAVAILABLE", message = msg) 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) { withContext(Dispatchers.IO) {
val socket = Socket() if (tls != null) {
try {
connectWithSocket(endpoint, hello, tls)
return@withContext
} catch (err: Throwable) {
if (tls.required) throw err
}
}
connectWithSocket(endpoint, hello, null)
}
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.tcpNoDelay = true
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000) socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
socket.soTimeout = 0 socket.soTimeout = 0
startTlsHandshakeIfNeeded(socket)
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
@@ -242,7 +265,7 @@ class BridgeSession(
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) { when (frame["type"].asStringOrNull()) {
"event" -> { "event" -> {
val event = frame["event"].asStringOrNull() ?: return@withContext val event = frame["event"].asStringOrNull() ?: continue
val payload = frame["payloadJSON"].asStringOrNull() val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload) onEvent(event, payload)
} }
@@ -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' }
}
+39 -1
View File
@@ -10,10 +10,36 @@ actor BridgeClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, 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 onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() 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") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await self.withTimeout(seconds: 8, purpose: "connect") { 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 { private struct TimeoutError: LocalizedError, Sendable {
var purpose: String var purpose: String
var seconds: Int var seconds: Int
@@ -10,6 +10,7 @@ protocol BridgePairingClient: Sendable {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
} }
@@ -115,9 +116,12 @@ final class BridgeConnectionController {
self.didAutoConnect = true self.didAutoConnect = true
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port) let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
self.startAutoConnect( self.startAutoConnect(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint), bridgeStableID: stableID,
tls: tlsParams,
token: token, token: token,
instanceId: instanceId) instanceId: instanceId)
return return
@@ -135,10 +139,12 @@ final class BridgeConnectionController {
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( self.startAutoConnect(
endpoint: target.endpoint, endpoint: target.endpoint,
bridgeStableID: target.stableID, bridgeStableID: target.stableID,
tls: tlsParams,
token: token, token: token,
instanceId: instanceId) instanceId: instanceId)
} }
@@ -182,6 +188,7 @@ final class BridgeConnectionController {
private func startAutoConnect( private func startAutoConnect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String, bridgeStableID: String,
tls: BridgeTLSParams?,
token: String, token: String,
instanceId: String) instanceId: String)
{ {
@@ -193,6 +200,7 @@ final class BridgeConnectionController {
let refreshed = try await self.bridgeClientFactory().pairAndHello( let refreshed = try await self.bridgeClientFactory().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
appModel.bridgeStatusText = status appModel.bridgeStatusText = status
@@ -208,6 +216,7 @@ final class BridgeConnectionController {
appModel.connectToBridge( appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: bridgeStableID, bridgeStableID: bridgeStableID,
tls: tls,
hello: self.makeHello(token: resolvedToken)) hello: self.makeHello(token: resolvedToken))
} catch { } catch {
await MainActor.run { 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 { private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName" let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -23,6 +23,8 @@ final class BridgeDiscoveryModel {
var gatewayPort: Int? var gatewayPort: Int?
var bridgePort: Int? var bridgePort: Int?
var canvasPort: Int? var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String? var cliPath: String?
} }
@@ -90,6 +92,8 @@ final class BridgeDiscoveryModel {
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
bridgePort: Self.txtIntValue(txt, key: "bridgePort"), bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"), canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath")) cliPath: Self.txtValue(txt, key: "cliPath"))
default: default:
return nil return nil
@@ -214,4 +218,9 @@ final class BridgeDiscoveryModel {
guard let raw = self.txtValue(dict, key: key) else { return nil } guard let raw = self.txtValue(dict, key: key) else { return nil }
return Int(raw) 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"
}
} }
+41 -2
View File
@@ -69,15 +69,42 @@ actor BridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws async throws
{ {
await self.disconnect() await self.disconnect()
self.state = .connecting 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 private func connectOnce(
params.includePeerToPeer = true 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 connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
self.connection = connection self.connection = connection
@@ -255,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 { private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return } guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [ cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
+66
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 }
}
+20 -9
View File
@@ -190,14 +190,7 @@ actor CameraController {
} }
func listDevices() -> [CameraDeviceInfo] { func listDevices() -> [CameraDeviceInfo] {
let types: [AVCaptureDevice.DeviceType] = [ return Self.discoverVideoDevices().map { device in
.builtInWideAngleCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices.map { device in
CameraDeviceInfo( CameraDeviceInfo(
id: device.uniqueID, id: device.uniqueID,
name: device.localizedName, name: device.localizedName,
@@ -232,7 +225,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { 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 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 { nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9 let q = quality ?? 0.9
return min(1.0, max(0.05, q)) return min(1.0, max(0.05, q))
@@ -205,6 +205,7 @@ final class NodeAppModel {
func connectToBridge( func connectToBridge(
endpoint: NWEndpoint, endpoint: NWEndpoint,
bridgeStableID: String, bridgeStableID: String,
tls: BridgeTLSParams?,
hello: BridgeHello) hello: BridgeHello)
{ {
self.bridgeTask?.cancel() self.bridgeTask?.cancel()
@@ -232,6 +233,7 @@ final class NodeAppModel {
try await self.bridge.connect( try await self.bridge.connect(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tls,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName, mainSessionKey in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{ {
{ sample, type, error in { sample, type, error in
let sampleBox = UncheckedSendableBox(value: sample)
// ReplayKit can call the capture handler on a background queue. // ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts. // Serialize writes to avoid queue asserts.
recordQueue.async { recordQueue.async {
let sample = sampleBox.value
if let error { if let error {
state.withLock { state in state.withLock { state in
if state.handlerError == nil { state.handlerError = error } if state.handlerError == nil { state.handlerError = error }
+49 -1
View File
@@ -407,9 +407,11 @@ struct SettingsTab: View {
modelIdentifier: self.modelIdentifier(), modelIdentifier: self.modelIdentifier(),
caps: self.currentCaps(), caps: self.currentCaps(),
commands: self.currentCommands()) commands: self.currentCommands())
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -426,6 +428,7 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: bridge.endpoint, endpoint: bridge.endpoint,
bridgeStableID: bridge.stableID, bridgeStableID: bridge.stableID,
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, displayName: self.displayName,
@@ -462,6 +465,8 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil } defer { self.connectingBridgeID = nil }
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
do { do {
let statusStore = self.connectStatus let statusStore = self.connectStatus
@@ -485,6 +490,7 @@ struct SettingsTab: View {
let token = try await BridgeClient().pairAndHello( let token = try await BridgeClient().pairAndHello(
endpoint: endpoint, endpoint: endpoint,
hello: hello, hello: hello,
tls: tlsParams,
onStatus: { status in onStatus: { status in
Task { @MainActor in Task { @MainActor in
statusStore.text = status statusStore.text = status
@@ -500,7 +506,8 @@ struct SettingsTab: View {
self.appModel.connectToBridge( self.appModel.connectToBridge(
endpoint: endpoint, endpoint: endpoint,
bridgeStableID: BridgeEndpointID.stableID(endpoint), bridgeStableID: stableID,
tls: tlsParams,
hello: BridgeHello( hello: BridgeHello(
nodeId: self.instanceId, nodeId: self.instanceId,
displayName: self.displayName, 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? { private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
+2 -1
View File
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift ../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
@@ -27,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient {
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: BridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)?) async throws -> String onStatus: (@Sendable (String) -> Void)?) async throws -> String
{ {
self.lastToken = hello.token self.lastToken = hello.token
@@ -244,6 +245,8 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "new-token") let mock = MockBridgePairingClient(resultToken: "new-token")
let account = "bridge-token.ios-test" let account = "bridge-token.ios-test"
@@ -292,6 +295,8 @@ private func withKeychainValues<T>(
gatewayPort: 18789, gatewayPort: 18789,
bridgePort: 18790, bridgePort: 18790,
canvasPort: 18793, canvasPort: 18793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge( let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
name: "Gateway B", name: "Gateway B",
@@ -303,6 +308,8 @@ private func withKeychainValues<T>(
gatewayPort: 28789, gatewayPort: 28789,
bridgePort: 28790, bridgePort: 28790,
canvasPort: 28793, canvasPort: 28793,
tlsEnabled: false,
tlsFingerprintSha256: nil,
cliPath: nil) cliPath: nil)
let mock = MockBridgePairingClient(resultToken: "token-ok") let mock = MockBridgePairingClient(resultToken: "token-ok")
+1 -1
View File
@@ -2,7 +2,7 @@ name: Clawdbot
options: options:
bundleIdPrefix: com.clawdbot bundleIdPrefix: com.clawdbot
deploymentTarget: deploymentTarget:
iOS: "17.0" iOS: "18.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
settings: settings:
+28 -1
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862", "originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
"pins" : [ "pins" : [
{ {
"identity" : "elevenlabskit", "identity" : "elevenlabskit",
@@ -73,6 +73,15 @@
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a" "revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
} }
}, },
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{ {
"identity" : "swift-configuration", "identity" : "swift-configuration",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -144,6 +153,24 @@
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3" "version" : "1.6.3"
} }
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
"version" : "0.2.0"
}
} }
], ],
"version" : 3 "version" : 3
+49 -25
View File
@@ -170,6 +170,10 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } 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). /// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false var canvasPanelVisible: Bool = false
@@ -253,30 +257,8 @@ final class AppState {
let configRoot = ClawdbotConfigFile.loadDict() let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = switch configModeRaw {
case "local":
.local
case "remote":
.remote
default:
nil
}
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
let resolvedConnectionMode: ConnectionMode = if let configMode {
configMode
} else if configHasRemoteUrl {
.remote
} else if let storedMode {
ConnectionMode(rawValue: storedMode) ?? .local
} else {
onboardingSeen ? .local : .unconfigured
}
self.connectionMode = resolvedConnectionMode self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
@@ -292,6 +274,7 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.systemRunPolicy = SystemRunPolicy.load()
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview { if !self.isPreview {
@@ -336,6 +319,15 @@ final class AppState {
return host 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() { private func startConfigWatcher() {
let configUrl = ClawdbotConfigFile.url() let configUrl = ClawdbotConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -401,6 +393,7 @@ final class AppState {
let connectionMode = self.connectionMode let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let desiredMode: String? = switch connectionMode { let desiredMode: String? = switch connectionMode {
case .local: case .local:
"local" "local"
@@ -430,15 +423,46 @@ final class AppState {
changed = true changed = true
} }
if connectionMode == .remote, let host = remoteHost { if connectionMode == .remote {
var remote = gateway["remote"] as? [String: Any] ?? [:] var remote = gateway["remote"] as? [String: Any] ?? [:]
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 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 parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789 let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl { if existingUrl != desiredUrl {
remote["url"] = 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 gateway["remote"] = remote
changed = true changed = true
} }
@@ -385,14 +385,8 @@ enum CommandResolver {
} }
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
let modeRaw = defaults.string(forKey: connectionModeKey) let root = ClawdbotConfigFile.loadDict()
let mode: AppState.ConnectionMode let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
if let modeRaw {
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
mode = seen ? .local : .unconfigured
}
let target = defaults.string(forKey: remoteTargetKey) ?? "" let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
@@ -0,0 +1,49 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
let configMode = configModeRaw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch configMode {
case "local":
return EffectiveConnectionMode(mode: .local, source: .configMode)
case "remote":
return EffectiveConnectionMode(mode: .remote, source: .configMode)
default:
break
}
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if !remoteURL.isEmpty {
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
}
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
}
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
}
}
@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
let remoteCliPathKey = "clawdbot.remoteCliPath" let remoteCliPathKey = "clawdbot.remoteCliPath"
let canvasEnabledKey = "clawdbot.canvasEnabled" let canvasEnabledKey = "clawdbot.canvasEnabled"
let cameraEnabledKey = "clawdbot.cameraEnabled" let cameraEnabledKey = "clawdbot.cameraEnabled"
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
let locationModeKey = "clawdbot.locationMode" let locationModeKey = "clawdbot.locationMode"
let locationPreciseKey = "clawdbot.locationPreciseEnabled" let locationPreciseKey = "clawdbot.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled" let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
@@ -188,9 +188,15 @@ extension CronJobEditor {
} }
} }
func applyDeleteAfterRun(to root: inout [String: Any]) { func applyDeleteAfterRun(
if self.scheduleKind == .at { to root: inout [String: Any],
root["deleteAfterRun"] = self.deleteAfterRun 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 { } else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false root["deleteAfterRun"] = false
} }
@@ -1,3 +1,4 @@
import ConcurrencyExtras
import Foundation import Foundation
import OSLog import OSLog
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
static let shared = GatewayEndpointStore() static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"] private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…" private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable {
case token
case password
}
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { token: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayToken( return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: isRemote,
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: isRemote,
root: root, root: root,
env: ProcessInfo.processInfo.environment, env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
!configPassword.isEmpty
{
self.warnEnvOverrideOnce(
kind: .password,
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
}
return trimmed return trimmed
} }
if isRemote { if isRemote {
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
return nil return nil
} }
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
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 resolveGatewayToken( private static func resolveGatewayToken(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
self.warnEnvOverrideOnce(
kind: .token,
envVar: "CLAWDBOT_GATEWAY_TOKEN",
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
}
return trimmed return trimmed
} }
if isRemote { if isRemote {
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
return nil return nil
} }
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
return token.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func warnEnvOverrideOnce(
kind: EnvOverrideWarningKind,
envVar: String,
configKey: String)
{
let shouldWarn = Self.envOverrideWarnings.withValue { state in
switch kind {
case .token:
guard !state.token else { return false }
state.token = true
return true
case .password:
guard !state.password else { return false }
state.password = true
return true
}
}
guard shouldWarn else { return }
Self.staticLogger.warning(
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
}
private let deps: Deps private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
@@ -2,77 +2,16 @@ import Foundation
enum GatewayLaunchAgentManager { enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") 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 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 { private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") .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 { static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } guard let loaded = await self.readDaemonLoaded() else { return false }
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) return loaded
return result.status == 0
} }
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
@@ -81,255 +20,44 @@ enum GatewayLaunchAgentManager {
self.logger.info("launchd enable skipped (disable marker set)") self.logger.info("launchd enable skipped (disable marker set)")
return nil return nil
} }
if enabled { if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) self.logger.info("launchd enable requested via CLI port=\(port)")
try? FileManager.default.removeItem(at: self.legacyPlistURL) return await self.runDaemonCommand([
"install",
let desiredBind = self.preferredGatewayBind() ?? "loopback" "--force",
let desiredToken = self.preferredGatewayToken() "--port",
let desiredPassword = self.preferredGatewayPassword() "\(port)",
let desiredConfig = DesiredConfig( "--runtime",
port: port, "node",
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 self.logger.info("launchd disable requested via CLI")
// change the config. `bootout` can kill a just-started gateway and cause attach loops. return await self.runDaemonCommand(["uninstall"])
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 disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
} }
static func kickstart() async { 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)")
}
}
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
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
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
{
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)
} }
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL) LaunchAgentPlist.snapshot(url: self.plistURL)
} }
private static func ensureEnabled() async { static func launchdGatewayLogPath() -> String {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let snapshot = self.launchdConfigSnapshot()
guard result.status != 0 else { return } if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines) !stdout.isEmpty
if msg.isEmpty { {
self.logger.warning("launchd enable failed") return stdout
} else {
self.logger.warning("launchd enable failed: \(msg)")
} }
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stderr.isEmpty
{
return stderr
} }
return LogLocator.launchdGatewayLogPath
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)")
}
} }
} }
@@ -339,20 +67,102 @@ extension GatewayLaunchAgentManager {
.appendingPathComponent(self.disableLaunchAgentMarker) .appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path) return FileManager.default.fileExists(atPath: marker.path)
} }
}
#if DEBUG private static func readDaemonLoaded() async -> Bool? {
extension GatewayLaunchAgentManager { let result = await self.runDaemonCommandResult(
static func _testPreferredGatewayBind() -> String? { ["status", "--json", "--no-probe"],
self.preferredGatewayBind() 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? { private struct CommandResult {
self.preferredGatewayToken() let success: Bool
let payload: Data?
let message: String?
} }
static func _testEscapePlistValue(_ raw: String) -> String { private struct ParsedDaemonJson {
self.escapePlistValue(raw) 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
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
self.status = .stopped self.status = .stopped
return 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.status = .starting
self.logger.debug("gateway start requested") self.logger.debug("gateway start requested")
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
func refreshLog() { func refreshLog() {
guard self.logRefreshTask == nil else { return } guard self.logRefreshTask == nil else { return }
let path = LogLocator.launchdGatewayLogPath let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
let limit = self.logLimit let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) { let log = await Task.detached(priority: .utility) {
@@ -354,7 +362,7 @@ final class GatewayProcessManager {
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared") self.logger.debug("gateway log cleared")
} }
@@ -83,6 +83,28 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) 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) { VStack(alignment: .leading, spacing: 6) {
Text("Location Access") Text("Location Access")
.font(.body) .font(.body)
@@ -92,7 +114,8 @@ struct GeneralSettings: View {
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue) Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue) Text("Always").tag(ClawdbotLocationMode.always.rawValue)
} }
.pickerStyle(.segmented) .labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled) Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off) .disabled(self.locationMode == .off)
@@ -395,7 +395,7 @@ extension InstancesSettings {
host: "phone", host: "phone",
ip: "10.0.0.3", ip: "10.0.0.3",
version: "2.0.0", version: "2.0.0",
platform: "iOS 17.2", platform: "iOS 18.0",
deviceFamily: "iPhone", deviceFamily: "iPhone",
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: 35, lastInputSeconds: 35,
@@ -446,7 +446,7 @@ extension InstancesSettings {
_ = view.platformIcon("watchOS 10") _ = view.platformIcon("watchOS 10")
_ = view.platformIcon("unknown 1.0") _ = view.platformIcon("unknown 1.0")
_ = view.prettyPlatform("macOS 14.2") _ = view.prettyPlatform("macOS 14.2")
_ = view.prettyPlatform("iOS 17") _ = view.prettyPlatform("iOS 18")
_ = view.prettyPlatform("ipados 17.1") _ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux") _ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ") _ = view.prettyPlatform(" ")
@@ -31,6 +31,8 @@ enum Launchctl {
struct LaunchAgentPlistSnapshot: Equatable, Sendable { struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String] let programArguments: [String]
let environment: [String: String] let environment: [String: String]
let stdoutPath: String?
let stderrPath: String?
let port: Int? let port: Int?
let bind: String? let bind: String?
@@ -53,6 +55,10 @@ enum LaunchAgentPlist {
guard let root = rootAny as? [String: Any] else { return nil } guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? [] let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: 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 port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased() let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
@@ -60,6 +66,8 @@ enum LaunchAgentPlist {
return LaunchAgentPlistSnapshot( return LaunchAgentPlistSnapshot(
programArguments: programArguments, programArguments: programArguments,
environment: env, environment: env,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
port: port, port: port,
bind: bind, bind: bind,
token: token, token: token,
@@ -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)
}
}
@@ -31,6 +31,12 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) 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 { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: self.activeBinding) { Toggle(isOn: self.activeBinding) {
@@ -68,6 +74,13 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) { Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera") 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 })) { Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
} }
@@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
extension MenuSessionsInjector { extension MenuSessionsInjector {
// MARK: - Injection // MARK: - Injection
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
@@ -11,10 +11,39 @@ actor MacNodeBridgePairingClient {
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
silent: Bool, 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 onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data() 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") let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
try await AsyncTimeout.withTimeout( 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( private func startAndWaitForReady(
_ connection: NWConnection, _ connection: NWConnection,
queue: DispatchQueue) async throws queue: DispatchQueue) async throws
@@ -36,6 +36,7 @@ actor MacNodeBridgeSession {
func connect( func connect(
endpoint: NWEndpoint, endpoint: NWEndpoint,
hello: BridgeHello, hello: BridgeHello,
tls: MacNodeBridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil, onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil, onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
@@ -44,15 +45,35 @@ actor MacNodeBridgeSession {
await self.disconnect() await self.disconnect()
self.disconnectHandler = onDisconnected self.disconnectHandler = onDisconnected
self.state = .connecting 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 private func connectOnce(
params.includePeerToPeer = true endpoint: NWEndpoint,
let tcpOptions = NWProtocolTCP.Options() hello: BridgeHello,
tcpOptions.enableKeepalive = true tls: MacNodeBridgeTLSParams?,
tcpOptions.keepaliveIdle = 30 onConnected: (@Sendable (String, String?) async -> Void)? = nil,
tcpOptions.keepaliveInterval = 15 onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
tcpOptions.keepaliveCount = 3 {
params.defaultProtocolStack.transportProtocol = tcpOptions let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params) let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session") let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection self.connection = connection
@@ -262,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 { private func failRPC(id: String, error: Error) async {
if let cont = self.pendingRPC.removeValue(forKey: id) { if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: error) cont.resume(throwing: error)
@@ -420,7 +460,7 @@ actor MacNodeBridgeSession {
do { do {
try await self.send(response) try await self.send(response)
} catch { } catch {
await self.logInvokeSendFailure(error) self.logInvokeSendFailure(error)
} }
} }
@@ -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)
}
@@ -4,6 +4,12 @@ import Foundation
import Network import Network
import OSLog import OSLog
private struct BridgeTarget {
let endpoint: NWEndpoint
let stableID: String
let tls: MacNodeBridgeTLSParams?
}
@MainActor @MainActor
final class MacNodeModeCoordinator { final class MacNodeModeCoordinator {
static let shared = MacNodeModeCoordinator() static let shared = MacNodeModeCoordinator()
@@ -37,6 +43,7 @@ final class MacNodeModeCoordinator {
private func run() async { private func run() async {
var retryDelay: UInt64 = 1_000_000_000 var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool? var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
while !Task.isCancelled { while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) { if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -53,7 +60,16 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000) 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)) try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000) retryDelay = min(retryDelay * 2, 10_000_000_000)
continue continue
@@ -63,10 +79,11 @@ final class MacNodeModeCoordinator {
do { do {
let hello = await self.makeHello() let hello = await self.makeHello()
self.logger.info( 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( try await self.session.connect(
endpoint: endpoint, endpoint: target.endpoint,
hello: hello, hello: hello,
tls: target.tls,
onConnected: { [weak self] serverName, mainSessionKey in onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)") self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey { if let mainSessionKey {
@@ -86,7 +103,7 @@ final class MacNodeModeCoordinator {
return await self.runtime.handleInvoke(req) return await self.runtime.handleInvoke(req)
}) })
} catch { } catch {
if await self.tryPair(endpoint: endpoint, error: error) { if await self.tryPair(target: target, error: error) {
continue continue
} }
self.logger.error( self.logger.error(
@@ -143,10 +160,14 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue, MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
] ]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps) let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) { if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.list.rawValue)
@@ -160,7 +181,7 @@ final class MacNodeModeCoordinator {
return commands 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() let text = error.localizedDescription.uppercased()
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false } guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
@@ -170,9 +191,10 @@ final class MacNodeModeCoordinator {
} }
let hello = await self.makeHello() let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello( let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: endpoint, endpoint: target.endpoint,
hello: hello, hello: hello,
silent: shouldSilent, silent: shouldSilent,
tls: target.tls,
onStatus: { [weak self] status in onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)") self?.logger.info("mac node pairing: \(status, privacy: .public)")
}) })
@@ -190,7 +212,7 @@ final class MacNodeModeCoordinator {
"mac-\(InstanceIdentity.instanceId)" "mac-\(InstanceIdentity.instanceId)"
} }
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? { private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
guard let port = Self.loopbackBridgePort(), guard let port = Self.loopbackBridgePort(),
let endpointPort = NWEndpoint.Port(rawValue: port) let endpointPort = NWEndpoint.Port(rawValue: port)
else { else {
@@ -198,7 +220,10 @@ final class MacNodeModeCoordinator {
} }
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort) let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds) 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? { static func loopbackBridgePort() -> UInt16? {
@@ -291,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 }) let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote { if mode == .remote {
do { do {
@@ -303,7 +328,10 @@ final class MacNodeModeCoordinator {
if healthy, let port = NWEndpoint.Port(rawValue: localPort) { if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info( self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)") "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( self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting") "mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
@@ -312,9 +340,23 @@ final class MacNodeModeCoordinator {
} }
let remotePort = Self.remoteBridgePort() 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( self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort, remotePort: remotePort,
allowRemoteUrlOverride: false) preferredLocalPort: preferredLocalPort,
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort, if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort) let port = NWEndpoint.Port(rawValue: localPort)
{ {
@@ -322,7 +364,10 @@ final class MacNodeModeCoordinator {
"mac node bridge tunnel ready " + "mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " + "localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, 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 { } catch {
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)") self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
@@ -333,8 +378,8 @@ final class MacNodeModeCoordinator {
tunnel.terminate() tunnel.terminate()
self.tunnel = nil self.tunnel = nil
} }
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) { if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return endpoint return target
} }
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds) return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
} }
@@ -354,14 +399,14 @@ final class MacNodeModeCoordinator {
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds) 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 { final class DiscoveryState: @unchecked Sendable {
let lock = NSLock() let lock = NSLock()
var resolved = false var resolved = false
var browsers: [NWBrowser] = [] var browsers: [NWBrowser] = []
var continuation: CheckedContinuation<NWEndpoint?, Never>? var continuation: CheckedContinuation<BridgeTarget?, Never>?
func finish(_ endpoint: NWEndpoint?) { func finish(_ target: BridgeTarget?) {
self.lock.lock() self.lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
if self.resolved { return } if self.resolved { return }
@@ -369,7 +414,7 @@ final class MacNodeModeCoordinator {
for browser in self.browsers { for browser in self.browsers {
browser.cancel() browser.cancel()
} }
self.continuation?.resume(returning: endpoint) self.continuation?.resume(returning: target)
self.continuation = nil self.continuation = nil
} }
} }
@@ -395,12 +440,12 @@ final class MacNodeModeCoordinator {
return false return false
}) })
{ {
state.finish(match.endpoint) state.finish(Self.targetFromResult(match))
return return
} }
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) { 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 browser.stateUpdateHandler = { browserState in
@@ -418,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 { enum MacNodeTokenStore {
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
return try await self.handleScreenRecordInvoke(req) return try await self.handleScreenRecordInvoke(req)
case ClawdbotSystemCommand.run.rawValue: case ClawdbotSystemCommand.run.rawValue:
return try await self.handleSystemRun(req) return try await self.handleSystemRun(req)
case ClawdbotSystemCommand.which.rawValue:
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue: case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req) return try await self.handleSystemNotify(req)
default: default:
@@ -426,6 +428,37 @@ actor MacNodeRuntime {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") 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 { if params.needsScreenRecording == true {
let authorized = await PermissionManager let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false .status([.screenRecording])[.screenRecording] ?? false
@@ -441,7 +474,7 @@ actor MacNodeRuntime {
let result = await ShellExecutor.runDetailed( let result = await ShellExecutor.runDetailed(
command: command, command: command,
cwd: params.cwd, cwd: params.cwd,
env: params.env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
struct RunPayload: Encodable { struct RunPayload: Encodable {
@@ -463,6 +496,33 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) 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 { private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON) let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -529,6 +589,39 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false 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 { private nonisolated static func locationMode() -> ClawdbotLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
return ClawdbotLocationMode(rawValue: raw) ?? .off return ClawdbotLocationMode(rawValue: raw) ?? .off
@@ -1,7 +1,14 @@
import AppKit
import ClawdbotKit import ClawdbotKit
import CoreLocation import CoreLocation
import Foundation import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
@MainActor @MainActor
protocol MacNodeRuntimeMainActorServices: Sendable { protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen( func recordScreen(
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy, desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?, maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
} }
@MainActor @MainActor
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
maxAgeMs: maxAgeMs, maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs) 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
}
}
} }
@@ -351,10 +351,11 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false return false
case .local: 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 full.contains("gateway-daemon") { return true }
// If args are unavailable, treat a clawdbot listener as expected. // 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: case .unconfigured:
return false return false
} }
@@ -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())
}
}
}
@@ -1679,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 struct ChatEvent: Codable, Sendable {
public let runid: String public let runid: String
public let sessionkey: String public let sessionkey: String
@@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
provider: "whatsapp", channel: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
var view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
onCancel: {}, onCancel: {},
onSave: { _ in }) 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() var root: [String: Any] = [:]
let raw = payload["deleteAfterRun"]?.value as? Bool view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
} }
@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low", thinking: "low",
timeoutSeconds: 15, timeoutSeconds: 15,
deliver: true, deliver: true,
provider: "whatsapp", channel: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: false) bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload) let data = try JSONEncoder().encode(payload)
@@ -3,10 +3,19 @@ import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct GatewayEndpointStoreTests { @Suite struct GatewayEndpointStoreTests {
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: "launchd-token", token: "launchd-token",
@@ -31,6 +40,8 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: "launchd-token", token: "launchd-token",
@@ -48,6 +59,8 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot( let snapshot = LaunchAgentPlistSnapshot(
programArguments: [], programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"], environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
stdoutPath: nil,
stderrPath: nil,
port: nil, port: nil,
bind: nil, bind: nil,
token: nil, token: nil,
@@ -60,4 +73,70 @@ import Testing
launchdSnapshot: snapshot) launchdSnapshot: snapshot)
#expect(password == "launchd-pass") #expect(password == "launchd-pass")
} }
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " local ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverTrimsConfigMode() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": " remote ",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
let defaults = self.makeDefaults()
defaults.set("remote", forKey: connectionModeKey)
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"mode": "staging",
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .local)
}
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
let defaults = self.makeDefaults()
defaults.set("local", forKey: connectionModeKey)
let root: [String: Any] = [
"gateway": [
"remote": [
"url": " ws://umbrel:18789 ",
],
],
]
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
} }
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
host: "gateway", host: "gateway",
ip: "10.0.0.4", ip: "10.0.0.4",
version: "3.0.0", version: "3.0.0",
platform: "iOS 17", platform: "iOS 18",
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
lastInputSeconds: nil, lastInputSeconds: nil,
@@ -112,20 +112,6 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address() _ = 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() { @Test func portGuardianParsesListenersAndBuildsReports() {
let output = """ let output = """
p123 p123
@@ -11,7 +11,8 @@ import Testing
stateversion: StateVersion(presence: 1, health: 1), stateversion: StateVersion(presence: 1, health: 1),
uptimems: 123, uptimems: 123,
configpath: nil, configpath: nil,
statedir: nil) statedir: nil,
sessiondefaults: nil)
let hello = HelloOk( let hello = HelloOk(
type: "hello", type: "hello",
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false) #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 { @Test func handleInvokeRejectsEmptyNotification() async throws {
let runtime = MacNodeRuntime() let runtime = MacNodeRuntime()
let params = ClawdbotSystemNotifyParams(title: "", body: "") let params = ClawdbotSystemNotifyParams(title: "", body: "")
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
{ {
CLLocation(latitude: 0, longitude: 0) CLLocation(latitude: 0, longitude: 0)
} }
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
.allowOnce
}
} }
let services = await MainActor.run { FakeMainActorServices() } let services = await MainActor.run { FakeMainActorServices() }
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
} }
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() { @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(7))
#expect(order.contains(3)) #expect(order.contains(3))
} }
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false) let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
#expect(!order.contains(8)) #expect(!order.contains(8))
} }
} }
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 30, timeoutSeconds: 30,
deliver: true, deliver: true,
provider: "sms", channel: "sms",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),
@@ -8,7 +8,7 @@ import Testing
let wav = makeWav16Mono(sampleRate: 8000, samples: 80) let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
defer { _ = TalkAudioPlayer.shared.stop() } defer { _ = TalkAudioPlayer.shared.stop() }
_ = try await withTimeout(seconds: 2.0) { _ = try await withTimeout(seconds: 4.0) {
await TalkAudioPlayer.shared.play(data: wav) await TalkAudioPlayer.shared.play(data: wav)
} }
@@ -27,7 +27,7 @@ import Testing
await Task.yield() await Task.yield()
_ = await TalkAudioPlayer.shared.play(data: wav) _ = await TalkAudioPlayer.shared.play(data: wav)
_ = try await withTimeout(seconds: 2.0) { _ = try await withTimeout(seconds: 4.0) {
await first.value await first.value
} }
#expect(true) #expect(true)
@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low") #expect(opts.thinking == "low")
#expect(opts.deliver == true) #expect(opts.deliver == true)
#expect(opts.to == nil) #expect(opts.to == nil)
#expect(opts.provider == .last) #expect(opts.channel == .last)
} }
} }
+9 -2
View File
@@ -5,7 +5,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ClawdbotKit", name: "ClawdbotKit",
platforms: [ platforms: [
.iOS(.v17), .iOS(.v18),
.macOS(.v15), .macOS(.v15),
], ],
products: [ products: [
@@ -14,6 +14,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
], ],
targets: [ targets: [
.target( .target(
@@ -29,7 +30,13 @@ let package = Package(
]), ]),
.target( .target(
name: "ClawdbotChatUI", name: "ClawdbotChatUI",
dependencies: ["ClawdbotKit"], dependencies: [
"ClawdbotKit",
.product(
name: "Textual",
package: "textual",
condition: .when(platforms: [.macOS, .iOS])),
],
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
@@ -0,0 +1,51 @@
import Foundation
enum ChatMarkdownPreprocessor {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import Textual
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
case standard
case compact
}
@MainActor
struct ChatMarkdownRenderer: View {
enum Context {
case user
case assistant
}
let text: String
let context: Context
let variant: ChatMarkdownVariant
let font: Font
let textColor: Color
var body: some View {
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
StructuredText(markdown: processed.cleaned)
.modifier(ChatMarkdownStyle(
variant: self.variant,
context: self.context,
font: self.font,
textColor: self.textColor))
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
private struct ChatMarkdownStyle: ViewModifier {
let variant: ChatMarkdownVariant
let context: ChatMarkdownRenderer.Context
let font: Font
let textColor: Color
func body(content: Content) -> some View {
Group {
if self.variant == .compact {
content.textual.structuredTextStyle(.default)
} else {
content.textual.structuredTextStyle(.gitHub)
}
}
.font(self.font)
.foregroundStyle(self.textColor)
.textual.inlineStyle(self.inlineStyle)
.textual.textSelection(.enabled)
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))
.link(.foregroundColor(linkColor))
}
}
@MainActor
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
@@ -1,114 +0,0 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}
@@ -137,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape {
struct ChatMessageBubble: View { struct ChatMessageBubble: View {
let message: ClawdbotChatMessage let message: ClawdbotChatMessage
let style: ClawdbotChatView.Style let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color? let userAccent: Color?
var body: some View { var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent) ChatMessageBody(
message: self.message,
isUser: self.isUser,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2) .padding(.horizontal, 2)
@@ -154,6 +160,7 @@ private struct ChatMessageBody: View {
let message: ClawdbotChatMessage let message: ClawdbotChatMessage
let isUser: Bool let isUser: Bool
let style: ClawdbotChatView.Style let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color? let userAccent: Color?
var body: some View { var body: some View {
@@ -169,39 +176,14 @@ private struct ChatMessageBody: View {
isUser: self.isUser) isUser: self.isUser)
} }
} else if self.isUser { } else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text) ChatMarkdownRenderer(
ForEach(split.blocks) { block in text: text,
switch block.kind { context: .user,
case .text: variant: self.markdownVariant,
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14)) font: .system(size: 14),
case let .code(language): textColor: textColor)
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else { } else {
Text(item.label.isEmpty ? "Image" : item.label) ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
} else {
ChatAssistantTextBody(text: text)
} }
if !self.inlineAttachments.isEmpty { if !self.inlineAttachments.isEmpty {
@@ -505,10 +487,11 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
@MainActor @MainActor
struct ChatStreamingAssistantBubble: View { struct ChatStreamingAssistantBubble: View {
let text: String let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ChatAssistantTextBody(text: self.text) ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
} }
.padding(12) .padding(12)
.background( .background(
@@ -612,114 +595,22 @@ private struct TypingDots: View {
} }
} }
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View { private struct ChatAssistantTextBody: View {
let text: String let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View { var body: some View {
let segments = AssistantTextParser.segments(from: self.text) let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font) ChatMarkdownRenderer(
text: segment.text,
context: .assistant,
variant: self.markdownVariant,
font: font,
textColor: ClawdbotChatTheme.assistantText)
} }
} }
} }
} }
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@@ -14,6 +14,7 @@ public struct ClawdbotChatView: View {
@State private var hasPerformedInitialScroll = false @State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool private let showsSessionSwitcher: Bool
private let style: Style private let style: Style
private let markdownVariant: ChatMarkdownVariant
private let userAccent: Color? private let userAccent: Color?
private enum Layout { private enum Layout {
@@ -42,11 +43,13 @@ public struct ClawdbotChatView: View {
viewModel: ClawdbotChatViewModel, viewModel: ClawdbotChatViewModel,
showsSessionSwitcher: Bool = false, showsSessionSwitcher: Bool = false,
style: Style = .standard, style: Style = .standard,
markdownVariant: ChatMarkdownVariant = .standard,
userAccent: Color? = nil) userAccent: Color? = nil)
{ {
self._viewModel = State(initialValue: viewModel) self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher self.showsSessionSwitcher = showsSessionSwitcher
self.style = style self.style = style
self.markdownVariant = markdownVariant
self.userAccent = userAccent self.userAccent = userAccent
} }
@@ -151,7 +154,11 @@ public struct ClawdbotChatView: View {
@ViewBuilder @ViewBuilder
private var messageListRows: some View { private var messageListRows: some View {
ForEach(self.visibleMessages) { msg in ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent) ChatMessageBubble(
message: msg,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame( .frame(
maxWidth: .infinity, maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading) alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
@@ -172,7 +179,7 @@ public struct ClawdbotChatView: View {
} }
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) { if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text) ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@@ -2,6 +2,7 @@ import Foundation
public enum ClawdbotSystemCommand: String, Codable, Sendable { public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run" case run = "system.run"
case which = "system.which"
case notify = "system.notify" 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 struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
public var title: String public var title: String
public var body: String public var body: String
@@ -0,0 +1,20 @@
import Testing
@testable import ClawdbotChatUI
@Suite("ChatMarkdownPreprocessor")
struct ChatMarkdownPreprocessorTests {
@Test func extractsDataURLImages() {
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
let markdown = """
Hello
![Pixel](data:image/png;base64,\(base64))
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Hello")
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
}
+1
View File
@@ -78,6 +78,7 @@ Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
Key behaviors: Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability. - Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- A summary is posted to the main session (prefix `Cron`, configurable). - A summary is posted to the main session (prefix `Cron`, configurable).
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary. - `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal. - If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
+14 -4
View File
@@ -13,6 +13,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
2) Set the token for Clawdbot: 2) Set the token for Clawdbot:
- Env: `DISCORD_BOT_TOKEN=...` - Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.token: "..."`. - Or config: `channels.discord.token: "..."`.
- If both are set, config wins; env is fallback.
3) Invite the bot to your server with message permissions. 3) Invite the bot to your server with message permissions.
4) Start the gateway. 4) Start the gateway.
5) DM access is pairing by default; approve the pairing code on first contact. 5) DM access is pairing by default; approve the pairing code on first contact.
@@ -39,8 +40,8 @@ Minimal config:
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token. 1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it. 2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`). 3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`).
4. Run the gateway; it auto-starts the Discord channel when a token is available (env or config) and `channels.discord.enabled` is not `false`. 4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional). - If you prefer env vars, set `DISCORD_BOT_TOKEN` (and omit config).
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected. 5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel. 6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord <code>`. 7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve discord <code>`.
@@ -191,7 +192,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or - Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user. - Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**: - **`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. - `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. - **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"`). - **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 +228,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
actions: { actions: {
reactions: true, reactions: true,
stickers: true, stickers: true,
emojiUploads: true,
stickerUploads: true,
polls: true, polls: true,
permissions: true, permissions: true,
messages: true, messages: true,
@@ -237,6 +240,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
roleInfo: true, roleInfo: true,
roles: false, roles: false,
channelInfo: true, channelInfo: true,
channels: true,
voiceStatus: true, voiceStatus: true,
events: true, events: true,
moderation: false moderation: false
@@ -304,8 +308,9 @@ ack reaction after the bot replies.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter). - `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). - `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions) - `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` - `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`) - `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`) - `moderation` (timeout/kick/ban, default `false`)
@@ -321,6 +326,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| --- | --- | --- | | --- | --- | --- |
| reactions | enabled | React + list reactions + emojiList | | reactions | enabled | React + list reactions + emojiList |
| stickers | enabled | Send stickers | | stickers | enabled | Send stickers |
| emojiUploads | enabled | Upload emojis |
| stickerUploads | enabled | Upload stickers |
| polls | enabled | Create polls | | polls | enabled | Create polls |
| permissions | enabled | Channel permission snapshot | | permissions | enabled | Channel permission snapshot |
| messages | enabled | Read/send/edit/delete | | messages | enabled | Read/send/edit/delete |
@@ -330,6 +337,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| memberInfo | enabled | Member info | | memberInfo | enabled | Member info |
| roleInfo | enabled | Role list | | roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list | | channelInfo | enabled | Channel info + list |
| channels | enabled | Channel/category management |
| voiceStatus | enabled | Voice state lookup | | voiceStatus | enabled | Voice state lookup |
| events | enabled | List/create scheduled events | | events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove | | roles | disabled | Role add/remove |
@@ -352,6 +360,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported. - Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel. - Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default. - 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: Native command notes:
- The registered commands mirror Clawdbot’s chat commands. - The registered commands mirror Clawdbot’s chat commands.
@@ -362,6 +371,7 @@ The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions) - `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions` - `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage` - `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply` - `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins` - `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList` - `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
+2 -1
View File
@@ -17,9 +17,10 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused. - [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration. - [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). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes ## Notes
+1
View File
@@ -32,6 +32,7 @@ Details: [Plugins](/plugin)
2) Configure credentials: 2) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`) - Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*` - Or config: `channels.matrix.*`
- Config takes precedence over env; env is fallback.
3) Restart the gateway (or finish onboarding). 3) Restart the gateway (or finish onboarding).
4) DM access defaults to pairing; approve the pairing code on first contact. 4) DM access defaults to pairing; approve the pairing code on first contact.
+45 -17
View File
@@ -3,20 +3,43 @@ summary: "Microsoft Teams bot support status, capabilities, and configuration"
read_when: read_when:
- Working on MS Teams channel features - Working on MS Teams channel features
--- ---
# Microsoft Teams (Bot Framework) # Microsoft Teams (plugin)
> "Abandon all hope, ye who enter here." > "Abandon all hope, ye who enter here."
Updated: 2026-01-08 Updated: 2026-01-16
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards. Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install.
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/msteams
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/msteams
```
If you choose Teams during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner) ## Quick setup (beginner)
1) Create an **Azure Bot** (App ID + client secret + tenant ID). 1) Install the Microsoft Teams plugin.
2) Configure Clawdbot with those credentials. 2) Create an **Azure Bot** (App ID + client secret + tenant ID).
3) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel. 3) Configure Clawdbot with those credentials.
4) Install the Teams app package and start the gateway. 4) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
5) Install the Teams app package and start the gateway.
Minimal config: Minimal config:
```json5 ```json5
@@ -73,11 +96,12 @@ Example:
``` ```
## How it works ## How it works
1. Create an **Azure Bot** (App ID + secret + tenant ID). 1. Install the Microsoft Teams plugin.
2. Build a **Teams app package** that references the bot and includes the RSC permissions below. 2. Create an **Azure Bot** (App ID + secret + tenant ID).
3. Upload/install the Teams app into a team (or personal scope for DMs). 3. Build a **Teams app package** that references the bot and includes the RSC permissions below.
4. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway. 4. Upload/install the Teams app into a team (or personal scope for DMs).
5. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default. 5. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
## Azure Bot Setup (Prerequisites) ## Azure Bot Setup (Prerequisites)
@@ -166,13 +190,17 @@ This is often easier than hand-editing JSON manifests.
3. Check gateway logs for incoming activity 3. Check gateway logs for incoming activity
## Setup (minimal text-only) ## Setup (minimal text-only)
1. **Bot registration** 1. **Install the Microsoft Teams plugin**
- From npm: `clawdbot plugins install @clawdbot/msteams`
- From a local checkout: `clawdbot plugins install ./extensions/msteams`
2. **Bot registration**
- Create an Azure Bot (see above) and note: - Create an Azure Bot (see above) and note:
- App ID - App ID
- Client secret (App password) - Client secret (App password)
- Tenant ID (single-tenant) - Tenant ID (single-tenant)
2. **Teams app manifest** 3. **Teams app manifest**
- Include a `bot` entry with `botId = <App ID>`. - Include a `bot` entry with `botId = <App ID>`.
- Scopes: `personal`, `team`, `groupChat`. - Scopes: `personal`, `team`, `groupChat`.
- `supportsFiles: true` (required for personal scope file handling). - `supportsFiles: true` (required for personal scope file handling).
@@ -180,7 +208,7 @@ This is often easier than hand-editing JSON manifests.
- Create icons: `outline.png` (32x32) and `color.png` (192x192). - Create icons: `outline.png` (32x32) and `color.png` (192x192).
- Zip all three files together: `manifest.json`, `outline.png`, `color.png`. - Zip all three files together: `manifest.json`, `outline.png`, `color.png`.
3. **Configure Clawdbot** 4. **Configure Clawdbot**
```json ```json
{ {
"msteams": { "msteams": {
@@ -198,12 +226,12 @@ This is often easier than hand-editing JSON manifests.
- `MSTEAMS_APP_PASSWORD` - `MSTEAMS_APP_PASSWORD`
- `MSTEAMS_TENANT_ID` - `MSTEAMS_TENANT_ID`
4. **Bot endpoint** 5. **Bot endpoint**
- Set the Azure Bot Messaging Endpoint to: - Set the Azure Bot Messaging Endpoint to:
- `https://<host>:3978/api/messages` (or your chosen path/port). - `https://<host>:3978/api/messages` (or your chosen path/port).
5. **Run the gateway** 6. **Run the gateway**
- The Teams channel starts automatically when `msteams` config exists and credentials are set. - The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials.
## History context ## History context
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. - `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
+88 -25
View File
@@ -27,17 +27,17 @@ Minimal config:
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and subscribe to: 4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
5) **Event Subscriptions** → enable events and subscribe to:
- `message.*` (includes edits/deletes/thread broadcasts) - `message.*` (includes edits/deletes/thread broadcasts)
- `app_mention` - `app_mention`
- `reaction_added`, `reaction_removed` - `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel` - `member_joined_channel`, `member_left_channel`
- `channel_id_changed`
- `channel_rename` - `channel_rename`
- `pin_added`, `pin_removed` - `pin_added`, `pin_removed`
5) Invite the bot to channels you want it to read. 6) Invite the bot to channels you want it to read.
6) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off). 7) Slash Commands → create `/clawd` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
7) App Home → enable the **Messages Tab** so users can DM the bot. 8) App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync. Use the manifest below so scopes and events stay in sync.
@@ -63,27 +63,59 @@ Or via config:
} }
``` ```
## History context ## User token (optional)
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). pins, reactions, emoji, member info). By default this stays read-only: reads
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`. prefer the user token when present, and writes still use the bot token unless
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
preferred for writes when it is available.
## Config writes User tokens are configured in the config file (no env var support). For
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. multi-account, set `channels.slack.accounts.<id>.userToken`.
This happens when: Example with bot + app + user tokens:
- Slack emits `channel_id_changed` (e.g. Slack Connect channel ID changes). Clawdbot can migrate `channels.slack.channels` automatically.
- You run `/config set` or `/config unset` in Slack (requires `commands.config: true`).
Disable with:
```json5 ```json5
{ {
channels: { slack: { configWrites: false } } channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-..."
}
}
} }
``` ```
Example with userTokenReadOnly explicitly set (allow user token writes):
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-...",
userTokenReadOnly: false
}
}
}
```
### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token.
## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.
```json ```json
{ {
@@ -133,6 +165,21 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"commands", "commands",
"files:read", "files:read",
"files:write" "files:write"
],
"user": [
"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"
] ]
} }
}, },
@@ -149,7 +196,6 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i
"reaction_removed", "reaction_removed",
"member_joined_channel", "member_joined_channel",
"member_left_channel", "member_left_channel",
"channel_id_changed",
"channel_rename", "channel_rename",
"pin_added", "pin_added",
"pin_removed" "pin_removed"
@@ -166,7 +212,7 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See conversation types you actually touch (channels, groups, im, mpim). See
https://api.channels.slack.com/docs/conversations-api for the overview. https://api.channels.slack.com/docs/conversations-api for the overview.
### Required scopes ### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`) - `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.channels.slack.com/methods/chat.postMessage https://api.channels.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs) - `im:write` (open DMs via `conversations.open` for user DMs)
@@ -188,6 +234,17 @@ https://api.channels.slack.com/docs/conversations-api for the overview.
- `files:write` (uploads via `files.uploadV2`) - `files:write` (uploads via `files.uploadV2`)
https://api.channels.slack.com/messaging/files/uploading https://api.channels.slack.com/messaging/files/uploading
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- `users:read`
- `reactions:read`
- `pins:read`
- `emoji:read`
- `search:read`
### Not needed today (but likely future) ### Not needed today (but likely future)
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive) - `groups:write` (only if we add private-channel management: create/rename/invite/archive)
@@ -269,11 +326,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Thread session isolation
Slack thread sessions are isolated by default. Configure with:
- `channels.slack.thread.historyScope`: `thread` (default) keeps per-thread history; `channel` shares history across the channel.
- `channels.slack.thread.inheritParent`: `false` (default) starts a clean thread session; `true` copies the parent channel transcript into the thread session.
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
@@ -320,6 +372,17 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
| memberInfo | enabled | Member info | | memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list | | emojiList | enabled | Custom emoji list |
## Security notes
- Writes default to the bot token so state-changing actions stay scoped to the
app's bot permissions and identity.
- Setting `userTokenReadOnly: false` allows the user token to be used for write
operations when a bot token is unavailable, which means actions run with the
installing user's access. Treat the user token as highly privileged and keep
action gates and allowlists tight.
- If you enable user-token writes, make sure the user token includes the write
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
## Notes ## Notes
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions. - Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
+150 -2
View File
@@ -13,6 +13,7 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
2) Set the token: 2) Set the token:
- Env: `TELEGRAM_BOT_TOKEN=...` - Env: `TELEGRAM_BOT_TOKEN=...`
- Or config: `channels.telegram.botToken: "..."`. - Or config: `channels.telegram.botToken: "..."`.
- If both are set, config wins; env is fallback.
3) Start the gateway. 3) Start the gateway.
4) DM access is pairing by default; approve the pairing code on first contact. 4) DM access is pairing by default; approve the pairing code on first contact.
@@ -60,11 +61,11 @@ Example:
} }
``` ```
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account). Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account; used only when config is missing).
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
3) Start the gateway. Telegram starts when a token is resolved (env or config). 3) Start the gateway. Telegram starts when a token is resolved (config first, env fallback).
4) DM access defaults to pairing. Approve the code when the bot is first contacted. 4) DM access defaults to pairing. Approve the code when the bot is first contacted.
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists. 5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
@@ -101,6 +102,29 @@ group messages, so use admin if you need full visibility.
- Raw HTML from models is escaped to avoid Telegram parse errors. - Raw HTML from models is escaped to avoid Telegram parse errors.
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text. - If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
## Commands (native + custom)
Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup.
You can add custom commands to the menu via config:
```json5
{
channels: {
telegram: {
customCommands: [
{ command: "backup", description: "Git backup" },
{ command: "generate", description: "Create an image" }
]
}
}
}
```
Notes:
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
## Limits ## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
@@ -188,11 +212,88 @@ Disable with:
Telegram forum topics include a `message_thread_id` per message. Clawdbot: Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated. - Appends `:topic:<threadId>` to the Telegram group session key so each topic is isolated.
- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic. - Sends typing indicators and replies with `message_thread_id` so responses stay in the topic.
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable). - Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present. Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
## Inline Buttons
Telegram supports inline keyboards with callback buttons.
```json5
{
"channels": {
"telegram": {
"capabilities": {
"inlineButtons": "allowlist"
}
}
}
}
```
For per-account configuration:
```json5
{
"channels": {
"telegram": {
"accounts": {
"main": {
"capabilities": {
"inlineButtons": "allowlist"
}
}
}
}
}
}
```
Scopes:
- `off` — inline buttons disabled
- `dm` — only DMs (group targets blocked)
- `group` — only groups (DM targets blocked)
- `all` — DMs + groups
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
Default: `allowlist`.
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
### Sending buttons
Use the message tool with the `buttons` parameter:
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"message": "Choose an option:",
"buttons": [
[
{"text": "Yes", "callback_data": "yes"},
{"text": "No", "callback_data": "no"}
],
[
{"text": "Cancel", "callback_data": "cancel"}
]
]
}
```
When a user clicks a button, the callback data is sent back to the agent as a message with the format:
`callback_data: value`
### Configuration options
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
## Access control (DMs + groups) ## Access control (DMs + groups)
### DM access ### DM access
@@ -297,6 +398,49 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled). - Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Reaction notifications
**How reactions work:**
Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot:
1. Receives the `message_reaction` update from Telegram API
2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"`
3. Enqueues the system event using the **same session key** as regular messages
4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context
The agent sees reactions as **system notifications** in the conversation history, not as message metadata.
**Configuration:**
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
- `"off"` — ignore all reactions
- `"own"` — notify when users react to bot messages (best-effort; in-memory) (default)
- `"all"` — notify for all reactions
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
- `"off"` — agent cannot react to messages
- `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default)
- `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges)
- `"extensive"` — agent can react liberally when appropriate
**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together.
**Example config:**
```json5
{
channels: {
telegram: {
reactionNotifications: "all", // See all reactions
reactionLevel: "minimal" // Agent can react sparingly
}
}
}
```
**Requirements:**
- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot)
- For webhook mode, reactions are included in the webhook `allowed_updates`
- For polling mode, reactions are included in the `getUpdates` `allowed_updates`
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target. - Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`. - Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
@@ -348,6 +492,8 @@ Provider options:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`. - `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group). - `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override. - `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
@@ -360,6 +506,8 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions. - `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. - `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. - `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
Related global options: Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns). - `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+10 -10
View File
@@ -82,15 +82,13 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
"selfChatMode": true, "selfChatMode": true,
"dmPolicy": "allowlist", "dmPolicy": "allowlist",
"allowFrom": ["+15551234567"] "allowFrom": ["+15551234567"]
},
"messages": {
"responsePrefix": "[clawdbot]"
} }
} }
``` ```
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix Self-chat replies default to `[{identity.name}]` when set (otherwise `[clawdbot]`)
on outbound replies. if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
the prefix (use `""` to remove it).
### Number sourcing tips ### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable) - **Local eSIM** from your country's mobile carrier (most reliable)
@@ -204,9 +202,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
- `always`: always triggers. - `always`: always triggers.
- `/activation mention|always` is owner-only and must be sent as a standalone message. - `/activation mention|always` is owner-only and must be sent as a standalone message.
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset). - Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
- **History injection**: - **History injection** (pending-only):
- Recent messages (default 50) inserted under: - Recent *unprocessed* messages (default 50) inserted under:
`[Chat messages since your last reply - for context]` `[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
- Current message under: - Current message under:
`[Current message - respond to this]` `[Current message - respond to this]`
- Sender suffix appended: `[from: Name (+E164)]` - Sender suffix appended: `[from: Name (+E164)]`
@@ -296,8 +294,9 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** is global (`agents.defaults.heartbeat.*`) and runs in the main session. - **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) + `HEARTBEAT_OK` skip behavior. via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used channel (or configured target). - Delivery defaults to the last used channel (or configured target).
## Reconnect behavior ## Reconnect behavior
@@ -330,6 +329,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
+98
View File
@@ -0,0 +1,98 @@
---
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
read_when:
- Setting up Zalo Personal for Clawdbot
- Debugging Zalo Personal login or message flow
---
# Zalo Personal (unofficial)
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
## Plugin required
Zalo Personal ships as a plugin and is not bundled with the core install.
- Install via CLI: `clawdbot plugins install @clawdbot/zalouser`
- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser`
- Details: [Plugins](/plugin)
## Prerequisite: zca-cli
The Gateway machine must have the `zca` binary available in `PATH`.
- Verify: `zca --version`
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
## Quick setup (beginner)
1) Install the plugin (see above).
2) Login (QR, on the Gateway machine):
- `clawdbot channels login --channel zalouser`
- Scan the QR code in the terminal with the Zalo mobile app.
3) Enable the channel:
```json5
{
channels: {
zalouser: {
enabled: true,
dmPolicy: "pairing"
}
}
}
```
4) Restart the Gateway (or finish onboarding).
5) DM access defaults to pairing; approve the pairing code on first contact.
## What it is
- Uses `zca listen` to receive inbound messages.
- Uses `zca msg ...` to send replies (text/media/link).
- Designed for “personal account” use cases where Zalo Bot API is not available.
## Naming
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
## Finding IDs (directory)
Use the directory CLI to discover peers/groups and their IDs:
```bash
clawdbot directory self --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory groups list --channel zalouser --query "work"
```
## Limits
- Outbound text is chunked to ~2000 characters (Zalo client limits).
- Streaming is blocked by default.
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
Approve via:
- `clawdbot pairing list zalouser`
- `clawdbot pairing approve zalouser <code>`
## Multi-account
Accounts map to zca profiles. Example:
```json5
{
channels: {
zalouser: {
enabled: true,
defaultAccount: "default",
accounts: {
work: { enabled: true, profile: "work" }
}
}
}
}
```
## Troubleshooting
**`zca` not found:**
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
**Login doesn’t stick:**
- `clawdbot channels status --probe`
- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser`
+41
View File
@@ -0,0 +1,41 @@
---
summary: "CLI reference for `clawdbot config` (get/set/unset config values)"
read_when:
- You want to read or edit config non-interactively
---
# `clawdbot config`
Config helpers: get/set/unset values by path. Run without a subcommand to open
the configure wizard (same as `clawdbot configure`).
## Examples
```bash
clawdbot config get browser.executablePath
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
clawdbot config set agents.defaults.heartbeat.every "2h"
clawdbot config unset tools.web.search.apiKey
```
## Paths
Paths use dot or bracket notation:
```bash
clawdbot config get agents.defaults.workspace
clawdbot config get agents.list[0].id
```
## Values
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
Use `--json` to require JSON5 parsing.
```bash
clawdbot config set agents.defaults.heartbeat.every "0m"
clawdbot config set gateway.port 19001 --json
clawdbot config set channels.whatsapp.groups '["*"]' --json
```
Restart the gateway after edits.
+9 -3
View File
@@ -1,15 +1,22 @@
--- ---
summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)" summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)"
read_when: read_when:
- You want to tweak credentials, devices, or agent defaults interactively - You want to tweak credentials, devices, or agent defaults interactively
--- ---
# `clawdbot configure` (alias: `config`) # `clawdbot configure`
Interactive prompt to set up credentials, devices, and agent defaults. Interactive prompt to set up credentials, devices, and agent defaults.
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
`clawdbot config get|set|unset` for non-interactive edits.
Related: Related:
- Gateway configuration reference: [Configuration](/gateway/configuration) - Gateway configuration reference: [Configuration](/gateway/configuration)
- Config CLI: [Config](/cli/config)
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
## Examples ## Examples
@@ -17,4 +24,3 @@ Related:
clawdbot configure clawdbot configure
clawdbot configure --section models --section channels clawdbot configure --section models --section channels
``` ```
+3
View File
@@ -15,3 +15,6 @@ Related:
Tip: run `clawdbot daemon --help` for platform-specific flags. Tip: run `clawdbot daemon --help` for platform-specific flags.
Notes:
- `daemon status` supports `--json` for scripting.
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
+60
View File
@@ -0,0 +1,60 @@
---
summary: "CLI reference for `clawdbot directory` (self, peers, groups)"
read_when:
- You want to look up contacts/groups/self ids for a channel
- You are developing a channel directory adapter
---
# `clawdbot directory`
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
## Common flags
- `--channel <name>`: channel id/alias (required when multiple channels are configured; auto when only one is configured)
- `--account <id>`: account id (default: channel default)
- `--json`: output JSON
## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
## Using results with `message send`
```bash
clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
```
## ID formats (by channel)
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
- Telegram: `@username` or numeric chat id; groups are numeric ids
- Slack: `user:U…` and `channel:C…`
- Discord: `user:<id>` and `channel:<id>`
- Matrix (plugin): `user:@user:server`, `room:!roomId:server`, or `#alias:server`
- Microsoft Teams (plugin): `user:<id>` and `conversation:<id>`
- Zalo (plugin): user id (Bot API)
- Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`)
## Self (“me”)
```bash
clawdbot directory self --channel zalouser
```
## Peers (contacts/users)
```bash
clawdbot directory peers list --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory peers list --channel zalouser --limit 50
```
## Groups
```bash
clawdbot directory groups list --channel zalouser
clawdbot directory groups list --channel zalouser --query "work"
clawdbot directory groups members --channel zalouser --group-id <id>
```
+11
View File
@@ -21,3 +21,14 @@ clawdbot doctor --repair
clawdbot doctor --deep clawdbot doctor --deep
``` ```
## macOS: `launchctl` env overrides
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
```bash
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
```
+1 -1
View File
@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and - your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**. - localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway. If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash ```bash
clawdbot gateway status clawdbot gateway status
+22 -6
View File
@@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do
- [`setup`](/cli/setup) - [`setup`](/cli/setup)
- [`onboard`](/cli/onboard) - [`onboard`](/cli/onboard)
- [`configure`](/cli/configure) (alias: `config`) - [`configure`](/cli/configure)
- [`config`](/cli/config)
- [`doctor`](/cli/doctor) - [`doctor`](/cli/doctor)
- [`dashboard`](/cli/dashboard) - [`dashboard`](/cli/dashboard)
- [`reset`](/cli/reset) - [`reset`](/cli/reset)
@@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
clawdbot [--dev] [--profile <name>] <command> clawdbot [--dev] [--profile <name>] <command>
setup setup
onboard onboard
configure (alias: config) configure
config
get
set
unset
doctor doctor
security security
audit audit
@@ -278,7 +283,7 @@ Options:
- `--non-interactive` - `--non-interactive`
- `--mode <local|remote>` - `--mode <local|remote>`
- `--flow <quickstart|advanced>` - `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>` - `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`) - `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`) - `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`) - `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -286,6 +291,7 @@ Options:
- `--anthropic-api-key <key>` - `--anthropic-api-key <key>`
- `--openai-api-key <key>` - `--openai-api-key <key>`
- `--openrouter-api-key <key>` - `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>` - `--moonshot-api-key <key>`
- `--gemini-api-key <key>` - `--gemini-api-key <key>`
- `--zai-api-key <key>` - `--zai-api-key <key>`
@@ -310,9 +316,18 @@ Options:
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime) - `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
- `--json` - `--json`
### `configure` / `config` ### `configure`
Interactive configuration wizard (models, channels, skills, gateway). Interactive configuration wizard (models, channels, skills, gateway).
### `config`
Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no
subcommand launches the wizard.
Subcommands:
- `config get <path>`: print a config value (dot/bracket path).
- `config set <path> <value>`: set a value (JSON5 or raw string).
- `config unset <path>`: remove a value.
### `doctor` ### `doctor`
Health checks + quick fixes (config + gateway + legacy services). Health checks + quick fixes (config + gateway + legacy services).
@@ -594,8 +609,9 @@ Notes:
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra". - `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL. - `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). - `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`. - `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
### `logs` ### `logs`
Tail Gateway file logs via RPC. Tail Gateway file logs via RPC.
@@ -785,7 +801,7 @@ Location:
## Browser ## Browser
Browser control CLI (dedicated Chrome/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser). Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options: Common options:
- `--url <controlUrl>` - `--url <controlUrl>`
+2 -2
View File
@@ -44,9 +44,9 @@ Target formats (`--to`):
- `send` - `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--to`, `--message` - Required: `--to`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`) - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id) - Telegram only: `--thread-id` (forum topic id)
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field) - Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
- WhatsApp only: `--gif-playback` - WhatsApp only: `--gif-playback`
+10
View File
@@ -22,6 +22,10 @@ clawdbot models set <model-or-alias>
clawdbot models scan clawdbot models scan
``` ```
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
## Aliases + fallbacks ## Aliases + fallbacks
```bash ```bash
@@ -33,7 +37,13 @@ clawdbot models fallbacks list
```bash ```bash
clawdbot models auth add clawdbot models auth add
clawdbot models auth login --provider <id>
clawdbot models auth setup-token clawdbot models auth setup-token
clawdbot models auth paste-token clawdbot models auth paste-token
``` ```
`models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use
`clawdbot plugins list` to see which providers are installed.
Notes:
- `setup-token` runs `claude setup-token` on the current machine (requires the Claude Code CLI).
- `paste-token` accepts a token string generated elsewhere.
+11
View File
@@ -21,6 +21,8 @@ clawdbot plugins info <id>
clawdbot plugins enable <id> clawdbot plugins enable <id>
clawdbot plugins disable <id> clawdbot plugins disable <id>
clawdbot plugins doctor clawdbot plugins doctor
clawdbot plugins update <id>
clawdbot plugins update --all
``` ```
### Install ### Install
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
Security note: treat plugin installs like running code. Prefer pinned versions. Security note: treat plugin installs like running code. Prefer pinned versions.
### Update
```bash
clawdbot plugins update <id>
clawdbot plugins update --all
clawdbot plugins update <id> --dry-run
```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
+1
View File
@@ -20,3 +20,4 @@ clawdbot security audit --deep
clawdbot security audit --fix clawdbot security audit --fix
``` ```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
+1 -1
View File
@@ -13,7 +13,7 @@ Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all).
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). - Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Context injection: **pending-only** group messages (default 50) that *did not* trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected.
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. - Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "<subject>". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat.
+3 -1
View File
@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
## Mention gating (default) ## Mention gating (default)
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5 ```json5
{ {
channels: { channels: {
@@ -219,7 +221,7 @@ Notes:
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group). - Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel). - Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels; use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable. - Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
## Group allowlists ## Group allowlists
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
+30
View File
@@ -33,6 +33,32 @@ Channels can redeliver the same message after reconnects. Clawdbot keeps a
short-lived cache keyed by channel/account/peer/session/message id so duplicate short-lived cache keyed by channel/account/peer/session/message id so duplicate
deliveries do not trigger another agent run. deliveries do not trigger another agent run.
## Inbound debouncing
Rapid consecutive messages from the **same sender** can be batched into a single
agent turn via `messages.inbound`. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
Config (global default + per-channel overrides):
```json5
{
messages: {
inbound: {
debounceMs: 2000,
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce applies to **text-only** messages; media/attachments flush immediately.
- Control commands bypass debouncing so they remain standalone.
## Sessions and devices ## Sessions and devices
Sessions are owned by the gateway, not by clients. Sessions are owned by the gateway, not by clients.
@@ -59,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]` - `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]` - `[Current message - respond to this]`
History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript.
Directive stripping only applies to the **current message** section so history Directive stripping only applies to the **current message** section so history
remains intact. Channels that wrap history should set `CommandBody` (or remains intact. Channels that wrap history should set `CommandBody` (or
`RawBody`) to the original message text and keep `Body` as the combined prompt. `RawBody`) to the original message text and keep `Body` as the combined prompt.
+11
View File
@@ -48,6 +48,17 @@ If no explicit order is configured, Clawdbot uses a round‑robin order:
- **Secondary key:** `usageStats.lastUsed` (oldest first, within each type). - **Secondary key:** `usageStats.lastUsed` (oldest first, within each type).
- **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry. - **Cooldown/disabled profiles** are moved to the end, ordered by soonest expiry.
### Session stickiness (cache-friendly)
Clawdbot **pins the chosen auth profile per session** to keep provider caches warm.
It does **not** rotate on every request. The pinned profile is reused until:
- the session is reset (`/new` / `/reset`)
- a compaction completes (compaction count increments)
- the profile is in cooldown/disabled
Manual selection via `/model …@<profileId>` sets a **user override** for that session
and is not auto‑rotated until a new session starts.
### Why OAuth can “look lost” ### Why OAuth can “look lost”
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile: If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
+7
View File
@@ -93,6 +93,13 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- CLI: `clawdbot onboard --auth-choice zai-api-key` - CLI: `clawdbot onboard --auth-choice zai-api-key`
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*` - Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
### Vercel AI Gateway
- Provider: `vercel-ai-gateway`
- Auth: `AI_GATEWAY_API_KEY`
- Example model: `vercel-ai-gateway/anthropic/claude-opus-4.5`
- CLI: `clawdbot onboard --auth-choice ai-gateway-api-key`
### Other built-in providers ### Other built-in providers
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`) - OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
+1 -1
View File
@@ -152,7 +152,7 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider). (effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring). Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
Preferred Anthropic auth is the Claude Code CLI setup-token (run on the gateway host): Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
```bash ```bash
claude setup-token claude setup-token
+7
View File
@@ -15,6 +15,13 @@ Clawdbot supports “subscription auth” via OAuth for providers that offer it
- how we **reuse external CLI tokens** (Claude Code / Codex CLI) - how we **reuse external CLI tokens** (Claude Code / Codex CLI)
- how to handle **multiple accounts** (profiles + per-session overrides) - how to handle **multiple accounts** (profiles + per-session overrides)
Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key
flows. Run them via:
```bash
clawdbot models auth login --provider <id>
```
## The token sink (why it exists) ## The token sink (why it exists)
OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app. OAuth providers commonly mint a **new refresh token** during login/refresh flows. Some providers (or OAuth clients) can invalidate older refresh tokens when a new one is issued for the same user/app.
+11 -11
View File
@@ -1,28 +1,28 @@
--- ---
summary: "Command queue design that serializes auto-reply command execution" summary: "Command queue design that serializes inbound auto-reply runs"
read_when: read_when:
- Changing auto-reply execution or concurrency - Changing auto-reply execution or concurrency
--- ---
# Command Queue (2026-01-03) # Command Queue (2026-01-16)
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions. We serialize inbound auto-reply runs (all channels) through a tiny in-process queue to prevent multiple agent runs from colliding, while still allowing safe parallelism across sessions.
## Why ## Why
- Some auto-reply commands are expensive (LLM calls) and can collide when multiple inbound messages arrive close together. - Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together.
- Serializing avoids competing for terminal/stdin, keeps logs readable, and reduces the chance of rate limits from upstream tools. - Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits.
## How it works ## How it works
- A lane-aware FIFO queue drains each lane synchronously. - A lane-aware FIFO queue drains each lane with a configurable concurrency cap (default 1).
- `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session. - `runEmbeddedPiAgent` enqueues by **session key** (lane `session:<key>`) to guarantee only one active run per session.
- Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`. - Each session run is then queued into a **global lane** (`main` by default) so overall parallelism is capped by `agents.defaults.maxConcurrent`.
- When verbose logging is enabled, queued commands emit a short notice if they waited more than ~2s before starting. - When verbose logging is enabled, queued runs emit a short notice if they waited more than ~2s before starting.
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn. - Typing indicators still fire immediately on enqueue (when supported by the channel) so user experience is unchanged while we wait our turn.
## Queue modes (per channel) ## Queue modes (per channel)
Inbound messages can steer the current run, wait for a followup turn, or do both: Inbound messages can steer the current run, wait for a followup turn, or do both:
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup. - `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
- `followup`: enqueue for the next agent turn after the current run ends. - `followup`: enqueue for the next agent turn after the current run ends.
- `collect`: coalesce all queued messages into a **single** followup turn (default). - `collect`: coalesce all queued messages into a **single** followup turn (default). If messages target different channels/threads, they drain individually to preserve routing.
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn. - `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
- `interrupt` (legacy): abort the active run for that session, then run the newest message. - `interrupt` (legacy): abort the active run for that session, then run the newest message.
- `queue` (legacy alias): same as `steer`. - `queue` (legacy alias): same as `steer`.
@@ -66,9 +66,9 @@ Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
- `/queue default` or `/queue reset` clears the session override. - `/queue default` or `/queue reset` clears the session override.
## Scope and guarantees ## Scope and guarantees
- Applies only to config-driven command replies; plain text replies are unaffected. - Applies to auto-reply agent runs across all inbound channels that use the gateway reply pipeline (WhatsApp web, Telegram, Slack, Discord, Signal, iMessage, webchat, etc.).
- Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel. - Default lane (`main`) is process-wide for inbound + main heartbeats; set `agents.defaults.maxConcurrent` to allow multiple sessions in parallel.
- Additional lanes may exist (e.g. `cron`) so background jobs can run in parallel without blocking inbound replies. - Additional lanes may exist (e.g. `cron`, `subagent`) so background jobs can run in parallel without blocking inbound replies.
- Per-session lanes guarantee that only one agent run touches a given session at a time. - Per-session lanes guarantee that only one agent run touches a given session at a time.
- No external dependencies or background worker threads; pure TypeScript + promises. - No external dependencies or background worker threads; pure TypeScript + promises.
+17 -2
View File
@@ -7,6 +7,12 @@ read_when:
Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored. Clawdbot treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.
Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
## Gateway is the source of truth ## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -32,8 +38,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
[Compaction](/concepts/compaction). [Compaction](/concepts/compaction).
## Mapping transports → session keys ## Mapping transports → session keys
- Direct chats collapse to the per-agent primary key: `agent:<agentId>:<mainKey>`. - Direct chats follow `session.dmScope` (default `main`).
- `main`: `agent:<agentId>:<mainKey>` (continuity across devices/channels).
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`). - Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation. - Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
- Legacy `group:<id>` keys are still recognized for migration. - Legacy `group:<id>` keys are still recognized for migration.
@@ -47,6 +57,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
## Send policy (optional) ## Send policy (optional)
Block delivery for specific session types without listing individual ids. Block delivery for specific session types without listing individual ids.
@@ -77,6 +88,10 @@ Send these as standalone messages so they register.
{ {
session: { session: {
scope: "per-sender", // keep group keys separate scope: "per-sender", // keep group keys separate
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 120, idleMinutes: 120,
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
@@ -91,7 +106,7 @@ Send these as standalone messages so they register.
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
- Send `/stop` as a standalone message to abort the current run. - Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
- Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
- JSONL transcripts can be opened directly to review full turns. - JSONL transcripts can be opened directly to review full turns.
Loaded 100 of 740 files, more files were not shown because too many files have changed in this diff. Show more