Compare commits

..
Author SHA1 Message Date
Peter Steinberger 99546ca3d9 fix: land sessions label edits (#1294) (thanks @bradleypriest) 2026-01-20 11:05:24 +00:00
Bradley Priest 26a5d02e69 ui(sessions): support editing session labels
Expose session "label" as an editable field in the Sessions view and persist changes via sessions.patch.
2026-01-20 10:57:36 +00:00
682 changed files with 21792 additions and 63381 deletions

No files matched your search

-3
View File
@@ -66,6 +66,3 @@ apps/ios/*.mobileprovision
IDENTITY.md IDENTITY.md
USER.md USER.md
.tgz .tgz
# local tooling
.serena/
+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
+1
View File
@@ -0,0 +1 @@
/cache
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []
-5
View File
@@ -41,11 +41,6 @@
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys. - Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
## Release Channels (Naming)
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
- dev: moving head on `main` (no tag; git checkout main).
## Testing Guidelines ## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
+10 -52
View File
@@ -2,61 +2,23 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.20 ## 2026.1.20-1
### Changes ### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - Gateway: raise default lane concurrency for main and sub-agent runs.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. - Config: centralize default agent concurrency limits.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
### Fixes ### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Cron: serialize scheduler operations per store path to prevent duplicate runs across hot reloads. (#1216) — thanks @carlulsoe.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- Nodes: enforce node.invoke timeouts for node handlers. (#1357) — thanks @vignesh07.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots. - Agents: treat OAuth refresh failures as auth errors to trigger model fallback. (#1261) — thanks @zknicker.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands. - CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik.
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) - Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander. - UI: allow editing session labels in the Sessions table. (#1294) — thanks @bradleypriest.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3 ## 2026.1.19-3
@@ -69,8 +31,6 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest. - UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
## 2026.1.19-2 ## 2026.1.19-2
@@ -93,7 +53,6 @@ Docs: https://docs.clawd.bot
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes ### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -106,7 +65,6 @@ Docs: https://docs.clawd.bot
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07. - TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169) - Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras. - CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5 ## 2026.1.18-5
+22 -32
View File
@@ -71,15 +71,6 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`). Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development) ## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
@@ -479,27 +470,26 @@ 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/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/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/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/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/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/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/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/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/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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
<a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></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/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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></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/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/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/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/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></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/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></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/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/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/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></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/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></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/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></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/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></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/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/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></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/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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></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/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/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></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/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/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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/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/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/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/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></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/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/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/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></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/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/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/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/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/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/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/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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/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/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/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/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/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/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/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></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/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/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/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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
<a href="https://github.com/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/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/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/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/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></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/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=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
<a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/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=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/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/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/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/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/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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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/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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+5 -5
View File
@@ -1,13 +1,13 @@
{ {
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3", "originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
"pins" : [ "pins" : [
{ {
"identity" : "commander", "identity" : "elevenlabskit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git", "location" : "https://github.com/steipete/ElevenLabsKit",
"state" : { "state" : {
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
"version" : "0.2.1" "version" : "0.1.0"
} }
}, },
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
## Clawdbot Node (Android) (internal) ## Clawdbot Node (Android) (internal)
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**. Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
Notes: Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action). - The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601200 versionCode = 202601114
versionName = "2026.1.20" versionName = "2026.1.11-4"
} }
buildTypes { buildTypes {
@@ -12,7 +12,6 @@ import com.clawdbot.android.chat.ChatMessage
import com.clawdbot.android.chat.ChatPendingToolCall import com.clawdbot.android.chat.ChatPendingToolCall
import com.clawdbot.android.chat.ChatSessionEntry import com.clawdbot.android.chat.ChatSessionEntry
import com.clawdbot.android.chat.OutgoingAttachment import com.clawdbot.android.chat.OutgoingAttachment
import com.clawdbot.android.gateway.DeviceAuthStore
import com.clawdbot.android.gateway.DeviceIdentityStore import com.clawdbot.android.gateway.DeviceIdentityStore
import com.clawdbot.android.gateway.GatewayClientInfo import com.clawdbot.android.gateway.GatewayClientInfo
import com.clawdbot.android.gateway.GatewayConnectOptions import com.clawdbot.android.gateway.GatewayConnectOptions
@@ -63,7 +62,6 @@ class NodeRuntime(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val prefs = SecurePrefs(appContext) val prefs = SecurePrefs(appContext)
private val deviceAuthStore = DeviceAuthStore(prefs)
val canvas = CanvasController() val canvas = CanvasController()
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext) val location = LocationCaptureManager(appContext)
@@ -155,7 +153,6 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey -> onConnected = { name, remote, mainSessionKey ->
operatorConnected = true operatorConnected = true
operatorStatusText = "Connected" operatorStatusText = "Connected"
@@ -191,7 +188,6 @@ class NodeRuntime(context: Context) {
GatewaySession( GatewaySession(
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { _, _, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" nodeStatusText = "Connected"
@@ -189,18 +189,6 @@ class SecurePrefs(context: Context) {
prefs.edit { putString(key, fingerprint.trim()) } prefs.edit { putString(key, fingerprint.trim()) }
} }
fun getString(key: String): String? {
return prefs.getString(key, null)
}
fun putString(key: String, value: String) {
prefs.edit { putString(key, value) }
}
fun remove(key: String) {
prefs.edit { remove(key) }
}
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
@@ -1,26 +0,0 @@
package com.clawdbot.android.gateway
import com.clawdbot.android.SecurePrefs
class DeviceAuthStore(private val prefs: SecurePrefs) {
fun loadToken(deviceId: String, role: String): String? {
val key = tokenKey(deviceId, role)
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
}
fun saveToken(deviceId: String, role: String, token: String) {
val key = tokenKey(deviceId, role)
prefs.putString(key, token.trim())
}
fun clearToken(deviceId: String, role: String) {
val key = tokenKey(deviceId, role)
prefs.remove(key)
}
private fun tokenKey(deviceId: String, role: String): String {
val normalizedDevice = deviceId.trim().lowercase()
val normalizedRole = role.trim().lowercase()
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
}
@@ -51,7 +51,7 @@ class GatewayDiscovery(
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = DnsResolver.getInstance() private val dns = DnsResolver.getInstance()
private val serviceType = "_clawdbot-gw._tcp." private val serviceType = "_clawdbot-gateway._tcp."
private val wideAreaDomain = "clawdbot.internal." private val wideAreaDomain = "clawdbot.internal."
private val logTag = "Clawdbot/GatewayDiscovery" private val logTag = "Clawdbot/GatewayDiscovery"
@@ -55,7 +55,6 @@ data class GatewayConnectOptions(
class GatewaySession( class GatewaySession(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore, private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
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,
@@ -178,7 +177,6 @@ class GatewaySession(
private val connectDeferred = CompletableDeferred<Unit>() private val connectDeferred = CompletableDeferred<Unit>()
private val closedDeferred = CompletableDeferred<Unit>() private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String?>()
private val client: OkHttpClient = buildClient() private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null private var socket: WebSocket? = null
private val loggerTag = "ClawdbotGateway" private val loggerTag = "ClawdbotGateway"
@@ -255,8 +253,7 @@ class GatewaySession(
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
scope.launch { scope.launch {
try { try {
val nonce = awaitConnectNonce() sendConnect()
sendConnect(nonce)
} catch (err: Throwable) { } catch (err: Throwable) {
connectDeferred.completeExceptionally(err) connectDeferred.completeExceptionally(err)
closeQuietly() closeQuietly()
@@ -291,30 +288,16 @@ class GatewaySession(
} }
} }
private suspend fun sendConnect(connectNonce: String?) { private suspend fun sendConnect() {
val identity = identityStore.loadOrCreate() val payload = buildConnectParams()
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
val trimmedToken = token?.trim().orEmpty()
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
val res = request("connect", payload, timeoutMs = 8_000) val res = request("connect", payload, timeoutMs = 8_000)
if (!res.ok) { if (!res.ok) {
val msg = res.error?.message ?: "connect failed" val msg = res.error?.message ?: "connect failed"
if (canFallbackToShared) {
deviceAuthStore.clearToken(identity.deviceId, options.role)
}
throw IllegalStateException(msg) throw IllegalStateException(msg)
} }
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
if (!deviceToken.isNullOrBlank()) {
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
}
val rawCanvas = obj["canvasHostUrl"].asStringOrNull() val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint) canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
val sessionDefaults = val sessionDefaults =
@@ -325,12 +308,7 @@ class GatewaySession(
connectDeferred.complete(Unit) connectDeferred.complete(Unit)
} }
private fun buildConnectParams( private fun buildConnectParams(): JsonObject {
identity: DeviceIdentity,
connectNonce: String?,
authToken: String,
authPassword: String?,
): JsonObject {
val client = options.client val client = options.client
val locale = Locale.getDefault().toLanguageTag() val locale = Locale.getDefault().toLanguageTag()
val clientObj = val clientObj =
@@ -345,20 +323,22 @@ class GatewaySession(
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
} }
val password = authPassword?.trim().orEmpty() val authToken = token?.trim().orEmpty()
val authPassword = password?.trim().orEmpty()
val authJson = val authJson =
when { when {
authToken.isNotEmpty() -> authToken.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("token", JsonPrimitive(authToken)) put("token", JsonPrimitive(authToken))
} }
password.isNotEmpty() -> authPassword.isNotEmpty() ->
buildJsonObject { buildJsonObject {
put("password", JsonPrimitive(password)) put("password", JsonPrimitive(authPassword))
} }
else -> null else -> null
} }
val identity = identityStore.loadOrCreate()
val signedAtMs = System.currentTimeMillis() val signedAtMs = System.currentTimeMillis()
val payload = val payload =
buildDeviceAuthPayload( buildDeviceAuthPayload(
@@ -369,7 +349,6 @@ class GatewaySession(
scopes = options.scopes, scopes = options.scopes,
signedAtMs = signedAtMs, signedAtMs = signedAtMs,
token = if (authToken.isNotEmpty()) authToken else null, token = if (authToken.isNotEmpty()) authToken else null,
nonce = connectNonce,
) )
val signature = identityStore.signPayload(payload, identity) val signature = identityStore.signPayload(payload, identity)
val publicKey = identityStore.publicKeyBase64Url(identity) val publicKey = identityStore.publicKeyBase64Url(identity)
@@ -380,9 +359,6 @@ class GatewaySession(
put("publicKey", JsonPrimitive(publicKey)) put("publicKey", JsonPrimitive(publicKey))
put("signature", JsonPrimitive(signature)) put("signature", JsonPrimitive(signature))
put("signedAt", JsonPrimitive(signedAtMs)) put("signedAt", JsonPrimitive(signedAtMs))
if (!connectNonce.isNullOrBlank()) {
put("nonce", JsonPrimitive(connectNonce))
}
} }
} else { } else {
null null
@@ -440,13 +416,6 @@ class GatewaySession(
val event = frame["event"].asStringOrNull() ?: return val event = frame["event"].asStringOrNull() ?: return
val payloadJson = val payloadJson =
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
if (event == "connect.challenge") {
val nonce = extractConnectNonce(payloadJson)
if (!connectNonceDeferred.isCompleted) {
connectNonceDeferred.complete(nonce)
}
return
}
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
handleInvokeEvent(payloadJson) handleInvokeEvent(payloadJson)
return return
@@ -454,21 +423,6 @@ class GatewaySession(
onEvent(event, payloadJson) onEvent(event, payloadJson)
} }
private suspend fun awaitConnectNonce(): String? {
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
null
}
}
private fun extractConnectNonce(payloadJson: String?): String? {
if (payloadJson.isNullOrBlank()) return null
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
return obj["nonce"].asStringOrNull()
}
private fun handleInvokeEvent(payloadJson: String) { private fun handleInvokeEvent(payloadJson: String) {
val payload = val payload =
try { try {
@@ -590,26 +544,19 @@ class GatewaySession(
scopes: List<String>, scopes: List<String>,
signedAtMs: Long, signedAtMs: Long,
token: String?, token: String?,
nonce: String?,
): String { ): String {
val scopeString = scopes.joinToString(",") val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty() val authToken = token.orEmpty()
val version = if (nonce.isNullOrBlank()) "v1" else "v2" return listOf(
val parts = "v1",
mutableListOf( deviceId,
version, clientId,
deviceId, clientMode,
clientId, role,
clientMode, scopeString,
role, signedAtMs.toString(),
scopeString, authToken,
signedAtMs.toString(), ).joinToString("|")
authToken,
)
if (!nonce.isNullOrBlank()) {
parts.add(nonce)
}
return parts.joinToString("|")
} }
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? { private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
@@ -84,7 +84,5 @@ private fun sha256Hex(data: ByteArray): String {
} }
private fun normalizeFingerprint(raw: String): String { private fun normalizeFingerprint(raw: String): String {
val stripped = raw.trim() return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
} }
+3 -3
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260120</string> <string>202601113</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -29,7 +29,7 @@
</dict> </dict>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_clawdbot-gw._tcp</string> <string>_clawdbot-gateway._tcp</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string> <string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
+9 -8
View File
@@ -1,13 +1,15 @@
Sources/Gateway/GatewayConnectionController.swift Sources/Bridge/BridgeClient.swift
Sources/Gateway/GatewayDiscoveryDebugLogView.swift Sources/Bridge/BridgeConnectionController.swift
Sources/Gateway/GatewayDiscoveryModel.swift Sources/Bridge/BridgeDiscoveryDebugLogView.swift
Sources/Gateway/GatewaySettingsStore.swift Sources/Bridge/BridgeDiscoveryModel.swift
Sources/Gateway/KeychainStore.swift Sources/Bridge/BridgeEndpointID.swift
Sources/Bridge/BridgeSession.swift
Sources/Bridge/BridgeSettingsStore.swift
Sources/Bridge/KeychainStore.swift
Sources/Camera/CameraController.swift Sources/Camera/CameraController.swift
Sources/Chat/ChatSheet.swift Sources/Chat/ChatSheet.swift
Sources/Chat/IOSGatewayChatTransport.swift Sources/Chat/IOSBridgeChatTransport.swift
Sources/ClawdbotApp.swift Sources/ClawdbotApp.swift
Sources/Location/LocationService.swift
Sources/Model/NodeAppModel.swift Sources/Model/NodeAppModel.swift
Sources/RootCanvas.swift Sources/RootCanvas.swift
Sources/RootTabs.swift Sources/RootTabs.swift
@@ -15,7 +17,6 @@ Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenTab.swift Sources/Screen/ScreenTab.swift
Sources/Screen/ScreenWebView.swift Sources/Screen/ScreenWebView.swift
Sources/SessionKey.swift
Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift Sources/Settings/VoiceWakeWordsSettingsView.swift
+3 -3
View File
@@ -7,11 +7,11 @@ import Testing
@Test func stableIDForServiceDecodesAndNormalizesName() { @Test func stableIDForServiceDecodesAndNormalizesName() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway \\032 Node\n", name: "Clawdbot\\032Gateway \\032 Node\n",
type: "_clawdbot-gw._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node") #expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
} }
@Test func stableIDForNonServiceUsesEndpointDescription() { @Test func stableIDForNonServiceUsesEndpointDescription() {
@@ -22,7 +22,7 @@ import Testing
@Test func prettyDescriptionDecodesBonjourEscapes() { @Test func prettyDescriptionDecodesBonjourEscapes() {
let endpoint = NWEndpoint.service( let endpoint = NWEndpoint.service(
name: "Clawdbot\\032Gateway", name: "Clawdbot\\032Gateway",
type: "_clawdbot-gw._tcp", type: "_clawdbot-gateway._tcp",
domain: "local.", domain: "local.",
interface: nil) interface: nil)
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260120</string> <string>202601113</string>
</dict> </dict>
</plist> </plist>
+5 -5
View File
@@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.20" CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260120" CFBundleVersion: "20260109"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -92,7 +92,7 @@ targets:
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true
NSBonjourServices: NSBonjourServices:
- _clawdbot-gw._tcp - _clawdbot-gateway._tcp
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway. NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing. NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always. NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.20" CFBundleShortVersionString: "2026.1.9"
CFBundleVersion: "20260120" CFBundleVersion: "20260109"
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33", "originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
"pins" : [ "pins" : [
{ {
"identity" : "axorcist", "identity" : "axorcist",
+12 -4
View File
@@ -12,7 +12,8 @@ let package = Package(
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
.executable(name: "Clawdbot", targets: ["Clawdbot"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]),
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]), .executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
@@ -66,13 +67,20 @@ let package = Package(
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
.executableTarget( .executableTarget(
name: "ClawdbotMacCLI", name: "ClawdbotDiscoveryCLI",
dependencies: [ dependencies: [
"ClawdbotDiscovery", "ClawdbotDiscovery",
.product(name: "ClawdbotKit", package: "ClawdbotKit"), ],
path: "Sources/ClawdbotDiscoveryCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "ClawdbotWizardCLI",
dependencies: [
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
], ],
path: "Sources/ClawdbotMacCLI", path: "Sources/ClawdbotWizardCLI",
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
]), ]),
@@ -81,7 +81,7 @@ private struct EventRow: View {
return f.string(from: date) return f.string(from: date)
} }
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? { private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value } let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized), guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
@@ -98,10 +98,7 @@ struct AgentEventsWindow_Previews: PreviewProvider {
seq: 1, seq: 1,
stream: "tool", stream: "tool",
ts: Date().timeIntervalSince1970 * 1000, ts: Date().timeIntervalSince1970 * 1000,
data: [ data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
"phase": ClawdbotProtocol.AnyCodable("start"),
"name": ClawdbotProtocol.AnyCodable("bash"),
],
summary: nil) summary: nil)
AgentEventStore.shared.append(sample) AgentEventStore.shared.append(sample)
return AgentEventsWindow() return AgentEventsWindow()
@@ -1,11 +1,6 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Foundation import Foundation
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = ClawdbotKit.AnyCodable
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
extension AnyCodable { extension AnyCodable {
var stringValue: String? { self.value as? String } var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool } var boolValue: Bool? { self.value as? Bool }
@@ -25,23 +20,3 @@ extension AnyCodable {
} }
} }
} }
extension ClawdbotProtocol.AnyCodable {
var stringValue: String? { self.value as? String }
var boolValue: Bool? { self.value as? Bool }
var intValue: Int? { self.value as? Int }
var doubleValue: Double? { self.value as? Double }
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
var foundationValue: Any {
switch self.value {
case let dict as [String: ClawdbotProtocol.AnyCodable]:
dict.mapValues { $0.foundationValue }
case let array as [ClawdbotProtocol.AnyCodable]:
array.map(\.foundationValue)
default:
self.value
}
}
}
@@ -426,17 +426,34 @@ extension ChannelsSettings {
} }
private func resolveChannelTitle(_ id: String) -> String { private func resolveChannelTitle(_ id: String) -> String {
let label = self.store.resolveChannelLabel(id) if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
if label != id { return label } return label
}
return id.prefix(1).uppercased() + id.dropFirst() return id.prefix(1).uppercased() + id.dropFirst()
} }
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
self.store.resolveChannelDetailLabel(id) switch id {
case "whatsapp": "WhatsApp Web"
case "telegram": "Telegram Bot"
case "discord": "Discord Bot"
case "slack": "Slack Bot"
case "signal": "Signal REST"
case "imessage": "iMessage"
default: self.resolveChannelTitle(id)
}
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
self.store.resolveChannelSystemImage(id) switch id {
case "whatsapp": "message"
case "telegram": "paperplane"
case "discord": "bubble.left.and.bubble.right"
case "slack": "number"
case "signal": "antenna.radiowaves.left.and.right"
case "imessage": "message.fill"
default: "message"
}
} }
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
@@ -153,19 +153,9 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable? let application: AnyCodable?
} }
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double let ts: Double
let channelOrder: [String] let channelOrder: [String]
let channelLabels: [String: String] let channelLabels: [String: String]
let channelDetailLabels: [String: String]?
let channelSystemImages: [String: String]?
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable] let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]] let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String] let channelDefaultAccountId: [String: String]
@@ -227,47 +217,6 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
} }
+24 -321
View File
@@ -6,19 +6,15 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore @Bindable var store: ChannelsStore
@State private var hasLoaded = false @State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
init(store: ChannelsStore = .shared) { init(store: ChannelsStore = .shared) {
self.store = store self.store = store
} }
var body: some View { var body: some View {
HStack(spacing: 16) { ScrollView {
self.sidebar self.content
self.detail
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task { .task {
guard !self.hasLoaded else { return } guard !self.hasLoaded else { return }
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -26,125 +22,42 @@ struct ConfigSettings: View {
await self.store.loadConfigSchema() await self.store.loadConfigSchema()
await self.store.loadConfig() await self.store.loadConfig()
} }
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
}
} }
} }
extension ConfigSettings { extension ConfigSettings {
private enum SubsectionSelection: Hashable { private var content: some View {
case all VStack(alignment: .leading, spacing: 16) {
case key(String) self.header
} if let status = self.store.configStatus {
Text(status)
private struct ConfigSection: Identifiable { .font(.callout)
let key: String .foregroundStyle(.secondary)
let label: String }
let help: String? self.actionRow
let node: ConfigSchemaNode Group {
if self.store.configSchemaLoading {
var id: String { self.key } ProgressView().controlSize(.small)
} } else if let schema = self.store.configSchema {
ConfigSchemaForm(store: self.store, schema: schema, path: [])
private struct ConfigSubsection: Identifiable { .disabled(self.isNixMode)
let key: String } else {
let label: String Text("Schema unavailable.")
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
var id: String { self.key }
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
}
private var sidebar: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
}
} }
} }
.padding(.vertical, 10) if self.store.configDirty, !self.isNixMode {
.padding(.horizontal, 10) Text("Unsaved changes")
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer(minLength: 0)
} }
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 18) .padding(.vertical, 18)
} .groupBoxStyle(PlainSettingsGroupBoxStyle())
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
} }
@ViewBuilder @ViewBuilder
@@ -158,18 +71,6 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View { private var actionRow: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Button("Reload") { Button("Reload") {
@@ -184,204 +85,6 @@ extension ConfigSettings {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
}
return node
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
} }
struct ConfigSettings_Previews: PreviewProvider { struct ConfigSettings_Previews: PreviewProvider {
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
let seq: Int let seq: Int
let stream: String let stream: String
let ts: Double let ts: Double
let data: [String: ClawdbotProtocol.AnyCodable] let data: [String: AnyCodable]
let summary: String? let summary: String?
} }
@@ -87,7 +87,15 @@ final class ControlChannel {
func configure() async { func configure() async {
self.logger.info("control channel configure mode=local") self.logger.info("control channel configure mode=local")
await self.refreshEndpoint(reason: "configure") self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
} }
func configure(mode: Mode = .local) async throws { func configure(mode: Mode = .local) async throws {
@@ -103,7 +111,7 @@ final class ControlChannel {
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.refreshEndpoint(reason: "configure") await self.configure()
} catch { } catch {
self.state = .degraded(error.localizedDescription) self.state = .degraded(error.localizedDescription)
throw error throw error
@@ -111,19 +119,6 @@ final class ControlChannel {
} }
} }
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.state = .connecting
do {
try await self.establishGatewayConnection()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func disconnect() async { func disconnect() async {
await GatewayConnection.shared.shutdown() await GatewayConnection.shared.shutdown()
self.state = .disconnected self.state = .disconnected
@@ -161,8 +156,8 @@ final class ControlChannel {
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
do { do {
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) { let rawParams = params?.reduce(into: [String: AnyCodable]()) {
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base) $0[$1.key] = AnyCodable($1.value.base)
} }
let data = try await GatewayConnection.shared.request( let data = try await GatewayConnection.shared.request(
method: method, method: method,
@@ -280,28 +275,18 @@ final class ControlChannel {
} }
} }
await self.refreshEndpoint(reason: "recovery:\(reasonText)") do {
if case .connected = self.state { try await GatewayConnection.shared.refresh()
self.logger.info("control channel recovery finished") self.logger.info("control channel recovery finished")
} else if case let .degraded(message) = self.state { } catch {
self.logger.error("control channel recovery failed \(message, privacy: .public)") self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
} }
self.recoveryTask = nil self.recoveryTask = nil
} }
} }
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params var merged = params
merged["text"] = AnyHashable(text) merged["text"] = AnyHashable(text)
@@ -361,7 +346,7 @@ final class ControlChannel {
let phase = event.data["phase"]?.value as? String ?? "" let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String let meta = event.data["meta"]?.value as? String
let args = Self.bridgeToProtocolArgs(event.data["args"]) let args = event.data["args"]?.value as? [String: AnyCodable]
WorkActivityStore.shared.handleTool( WorkActivityStore.shared.handleTool(
sessionKey: sessionKey, sessionKey: sessionKey,
phase: phase, phase: phase,
@@ -372,27 +357,6 @@ final class ControlChannel {
break break
} }
} }
private static func bridgeToProtocolArgs(
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
} }
extension Notification.Name { extension Notification.Name {
@@ -42,8 +42,7 @@ extension CronJobEditor {
self.thinking = thinking ?? "" self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false self.deliver = deliver ?? false
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) self.channel = GatewayAgentChannel(raw: channel)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? "" self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false self.bestEffortDeliver = bestEffortDeliver ?? false
} }
@@ -211,8 +210,7 @@ extension CronJobEditor {
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver payload["deliver"] = self.deliver
if self.deliver { if self.deliver {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) payload["channel"] = self.channel.rawValue
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to } if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver payload["bestEffortDeliver"] = self.bestEffortDeliver
@@ -14,7 +14,7 @@ extension CronJobEditor {
self.payloadKind = .agentTurn self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic" self.agentMessage = "Run diagnostic"
self.deliver = true self.deliver = true
self.channel = "last" self.channel = .last
self.to = "+15551230000" self.to = "+15551230000"
self.thinking = "low" self.thinking = "low"
self.timeoutSeconds = "90" self.timeoutSeconds = "90"
@@ -1,12 +1,10 @@
import ClawdbotProtocol import ClawdbotProtocol
import Observation
import SwiftUI import SwiftUI
struct CronJobEditor: View { struct CronJobEditor: View {
let job: CronJob? let job: CronJob?
@Binding var isSaving: Bool @Binding var isSaving: Bool
@Binding var error: String? @Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void let onSave: ([String: AnyCodable]) -> Void
@@ -47,29 +45,13 @@ struct CronJobEditor: View {
@State var systemEventText: String = "" @State var systemEventText: String = ""
@State var agentMessage: String = "" @State var agentMessage: String = ""
@State var deliver: Bool = false @State var deliver: Bool = false
@State var channel: String = "last" @State var channel: GatewayAgentChannel = .last
@State var to: String = "" @State var to: String = ""
@State var thinking: String = "" @State var thinking: String = ""
@State var timeoutSeconds: String = "" @State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false @State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron" @State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -351,9 +333,13 @@ struct CronJobEditor: View {
GridRow { GridRow {
self.gridLabel("Channel") self.gridLabel("Channel")
Picker("", selection: self.$channel) { Picker("", selection: self.$channel) {
ForEach(self.channelOptions, id: \.self) { channel in Text("last").tag(GatewayAgentChannel.last)
Text(self.channelLabel(for: channel)).tag(channel) Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
} Text("telegram").tag(GatewayAgentChannel.telegram)
Text("discord").tag(GatewayAgentChannel.discord)
Text("slack").tag(GatewayAgentChannel.slack)
Text("signal").tag(GatewayAgentChannel.signal)
Text("imessage").tag(GatewayAgentChannel.imessage)
} }
.labelsHidden() .labelsHidden()
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -8,20 +8,13 @@ extension CronSettings {
self.content self.content
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.onAppear { .onAppear { self.store.start() }
self.store.start() .onDisappear { self.store.stop() }
self.channelsStore.start()
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.sheet(isPresented: self.$showEditor) { .sheet(isPresented: self.$showEditor) {
CronJobEditor( CronJobEditor(
job: self.editingJob, job: self.editingJob,
isSaving: self.$isSaving, isSaving: self.$isSaving,
error: self.$editorError, error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: { onCancel: {
self.showEditor = false self.showEditor = false
self.editingJob = nil self.editingJob = nil
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
durationMs: 1234, durationMs: 1234,
nextRunAtMs: nil), nextRunAtMs: nil),
] ]
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
} }
} }
@@ -103,7 +103,7 @@ extension CronSettings {
store.selectedJobId = job.id store.selectedJobId = job.id
store.runEntries = [run] store.runEntries = [run]
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) let view = CronSettings(store: store)
_ = view.body _ = view.body
_ = view.jobRow(job) _ = view.jobRow(job)
_ = view.jobContextMenu(job) _ = view.jobContextMenu(job)
@@ -3,15 +3,13 @@ import SwiftUI
struct CronSettings: View { struct CronSettings: View {
@Bindable var store: CronJobsStore @Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
@State var showEditor = false @State var showEditor = false
@State var editingJob: CronJob? @State var editingJob: CronJob?
@State var editorError: String? @State var editorError: String?
@State var isSaving = false @State var isSaving = false
@State var confirmDelete: CronJob? @State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) { init(store: CronJobsStore = .shared) {
self.store = store self.store = store
self.channelsStore = channelsStore
} }
} }
@@ -46,7 +46,6 @@ private struct ExecHostRequest: Codable {
var needsScreenRecording: Bool? var needsScreenRecording: Bool?
var agentId: String? var agentId: String?
var sessionKey: String? var sessionKey: String?
var approvalDecision: ExecApprovalDecision?
} }
private struct ExecHostRunResult: Codable { private struct ExecHostRunResult: Codable {
@@ -259,20 +258,6 @@ enum ExecApprovalsPromptPresenter {
@MainActor @MainActor
private enum ExecHostExecutor { private enum ExecHostExecutor {
private struct ExecApprovalContext {
let command: [String]
let displayCommand: String
let trimmedAgent: String?
let approvals: ExecApprovalsResolved
let security: ExecSecurity
let ask: ExecAsk
let autoAllowSkills: Bool
let env: [String: String]?
let resolution: ExecCommandResolution?
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
private static let blockedEnvKeys: Set<String> = [ private static let blockedEnvKeys: Set<String> = [
"PATH", "PATH",
"NODE_OPTIONS", "NODE_OPTIONS",
@@ -291,93 +276,14 @@ private enum ExecHostExecutor {
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else { guard !command.isEmpty else {
return self.errorResponse( return ExecHostResponse(
code: "INVALID_REQUEST", type: "exec-res",
message: "command required", id: UUID().uuidString,
reason: "invalid") ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
} }
let context = await self.buildContext(request: request, command: command)
if context.security == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny")
}
let approvalDecision = request.approvalDecision
if approvalDecision == .deny {
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: context.displayCommand,
cwd: request.cwd,
host: "node",
security: context.security.rawValue,
ask: context.ask.rawValue,
agentId: context.trimmedAgent,
resolvedPath: context.resolution?.resolvedPath))
switch decision {
case .deny:
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied")
case .allowAlways:
approvedByAsk = true
self.persistAllowlistEntry(decision: decision, context: context)
case .allowOnce:
approvedByAsk = true
}
}
self.persistAllowlistEntry(decision: approvalDecision, context: context)
if context.security == .allowlist,
context.allowlistMatch == nil,
!context.skillAllow,
!approvedByAsk
{
return self.errorResponse(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss")
}
if let match = context.allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: context.trimmedAgent,
pattern: match.pattern,
command: context.displayCommand,
resolvedPath: context.resolution?.resolvedPath)
}
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
return errorResponse
}
return await self.runCommand(
command: command,
cwd: request.cwd,
env: context.env,
timeoutMs: request.timeoutMs)
}
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
let displayCommand = ExecCommandFormatter.displayString( let displayCommand = ExecCommandFormatter.displayString(
for: command, for: command,
rawCommand: request.rawCommand) rawCommand: request.rawCommand)
@@ -403,72 +309,102 @@ private enum ExecHostExecutor {
} else { } else {
skillAllow = false skillAllow = false
} }
return ExecApprovalContext(
command: command,
displayCommand: displayCommand,
trimmedAgent: trimmedAgent,
approvals: approvals,
security: security,
ask: ask,
autoAllowSkills: autoAllowSkills,
env: env,
resolution: resolution,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
}
private static func requiresAsk( if security == .deny {
ask: ExecAsk, return ExecHostResponse(
security: ExecSecurity, type: "exec-res",
allowlistMatch: ExecAllowlistEntry?, id: UUID().uuidString,
skillAllow: Bool) -> Bool ok: false,
{ payload: nil,
if ask == .always { return true } error: ExecHostError(
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } code: "UNAVAILABLE",
return false message: "SYSTEM_RUN_DISABLED: security=deny",
} reason: "security=deny"))
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
return
} }
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func allowlistPattern( let requiresAsk: Bool = {
command: [String], if ask == .always { return true }
resolution: ExecCommandResolution?) -> String? if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
{ return false
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" }()
return pattern.isEmpty ? nil : pattern
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { var approvedByAsk = false
guard needsScreenRecording == true else { return nil } if requiresAsk {
let authorized = await PermissionManager let decision = ExecApprovalsPromptPresenter.prompt(
.status([.screenRecording])[.screenRecording] ?? false ExecApprovalPromptRequest(
if authorized { return nil } command: displayCommand,
return self.errorResponse( cwd: request.cwd,
code: "UNAVAILABLE", host: "node",
message: "PERMISSION_MISSING: screenRecording", security: security.rawValue,
reason: "permission:screenRecording") ask: ask.rawValue,
} agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
private static func runCommand( switch decision {
command: [String], case .deny:
cwd: String?, return ExecHostResponse(
env: [String: String]?, type: "exec-res",
timeoutMs: Int?) async -> ExecHostResponse id: UUID().uuidString,
{ ok: false,
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(
code: "UNAVAILABLE",
message: "PERMISSION_MISSING: screenRecording",
reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed( await ShellExecutor.runDetailed(
command: command, command: command,
cwd: cwd, cwd: request.cwd,
env: env, env: env,
timeout: timeoutSec) timeout: timeoutSec)
}.value }.value
@@ -479,24 +415,7 @@ private enum ExecHostExecutor {
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
error: result.errorMessage) error: result.errorMessage)
return self.successResponse(payload) return ExecHostResponse(
}
private static func errorResponse(
code: String,
message: String,
reason: String?) -> ExecHostResponse
{
ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: code, message: message, reason: reason))
}
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
ExecHostResponse(
type: "exec-res", type: "exec-res",
id: UUID().uuidString, id: UUID().uuidString,
ok: true, ok: true,
@@ -15,7 +15,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case signal case signal
case imessage case imessage
case msteams case msteams
case bluebubbles
case webchat case webchat
init(raw: String?) { init(raw: String?) {
@@ -148,27 +147,6 @@ actor GatewayConnection {
} }
} }
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError throw lastError
case .remote: case .remote:
let nsError = error as NSError let nsError = error as NSError
@@ -265,9 +243,9 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String { private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String (defaults?[key]?.stringValue ?? "")
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
} }
func cachedMainSessionKey() -> String? { func cachedMainSessionKey() -> String? {
@@ -1,63 +0,0 @@
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
private var endpointTask: Task<Void, Never>?
private var lastResolvedURL: URL?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
private(set) var resolvedMode: AppState.ConnectionMode?
private(set) var resolvedHostLabel: String?
private init() {
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
await MainActor.run { self.handleEndpointState(state) }
}
}
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
private func handleEndpointState(_ state: GatewayEndpointState) {
self.endpointState = state
switch state {
case let .ready(mode, url, _, _):
self.resolvedMode = mode
self.resolvedURL = url
self.resolvedHostLabel = Self.hostLabel(for: url)
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
if urlChanged {
self.lastResolvedURL = url
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
}
case let .connecting(mode, _):
self.resolvedMode = mode
case let .unavailable(mode, _):
self.resolvedMode = mode
}
}
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
return host
}
}
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
return GatewayEndpointStore.resolveLocalGatewayHost( return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind, bindMode: bind,
customBindHost: customBindHost, customBindHost: customBindHost,
@@ -166,23 +165,19 @@ actor GatewayEndpointStore {
} }
return trimmed return trimmed
} }
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty !configToken.isEmpty
{ {
return configToken return configToken
} }
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
return token return token
} }
return nil return nil
} }
@@ -474,36 +469,6 @@ actor GatewayEndpointStore {
} }
} }
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
let mode = await self.deps.mode()
guard mode == .local else { return nil }
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
guard bind == "auto" else { return nil }
let currentHost = currentURL.host?.lowercased() ?? ""
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: root,
env: ProcessInfo.processInfo.environment)
let port = self.deps.localPort()
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
self.setState(.ready(mode: .local, url: url, token: token, password: password))
return (url, token, password)
}
private static func resolveGatewayBindMode( private static func resolveGatewayBindMode(
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String]) -> String?
@@ -559,17 +524,12 @@ actor GatewayEndpointStore {
tailscaleIP: String?) -> String tailscaleIP: String?) -> String
{ {
switch bindMode { switch bindMode {
case "tailnet": case "tailnet", "auto":
return tailscaleIP ?? "127.0.0.1" tailscaleIP ?? "127.0.0.1"
case "auto":
if let tailscaleIP, !tailscaleIP.isEmpty {
return tailscaleIP
}
return "127.0.0.1"
case "custom": case "custom":
return customBindHost ?? "127.0.0.1" customBindHost ?? "127.0.0.1"
default: default:
return "127.0.0.1" "127.0.0.1"
} }
} }
} }
@@ -640,12 +600,11 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost( static func _testResolveLocalGatewayHost(
bindMode: String?, bindMode: String?,
tailscaleIP: String?, tailscaleIP: String?) -> String
customBindHost: String? = nil) -> String
{ {
self.resolveLocalGatewayHost( self.resolveLocalGatewayHost(
bindMode: bindMode, bindMode: bindMode,
customBindHost: customBindHost, customBindHost: nil,
tailscaleIP: tailscaleIP) tailscaleIP: tailscaleIP)
} }
} }
@@ -235,8 +235,8 @@ final class HealthStore {
let lower = error.lowercased() let lower = error.lowercased()
if lower.contains("connection refused") { if lower.contains("connection refused") {
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back." "restart Clawdbot to bring it back."
} }
if lower.contains("timeout") { if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting." return "Timed out waiting for the control server; the gateway may be crashed or still starting."
@@ -13,7 +13,6 @@ struct ClawdbotApp: App {
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isPanelVisible = false @State private var isPanelVisible = false
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
} }
case .local: case .local:
platform = "local" platform = "local"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" host = "127.0.0.1:\(port)"
case .unconfigured: case .unconfigured:
platform = nil platform = nil
host = nil host = nil
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {
let step: WizardStep let step: WizardStep
let isSubmitting: Bool let isSubmitting: Bool
let onStepSubmit: (AnyCodable?) -> Void let onSubmit: (AnyCodable?) -> Void
@State private var textValue: String @State private var textValue: String
@State private var confirmValue: Bool @State private var confirmValue: Bool
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step self.step = step
self.isSubmitting = isSubmitting self.isSubmitting = isSubmitting
self.onStepSubmit = onSubmit self.onSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option) WizardOptionItem(index: index, option: option)
} }
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
private func submit() { private func submit() {
switch wizardStepType(self.step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":
self.onStepSubmit(nil) self.onSubmit(nil)
case "text": case "text":
self.onStepSubmit(AnyCodable(self.textValue)) self.onSubmit(AnyCodable(self.textValue))
case "confirm": case "confirm":
self.onStepSubmit(AnyCodable(self.confirmValue)) self.onSubmit(AnyCodable(self.confirmValue))
case "select": case "select":
guard self.optionItems.indices.contains(self.selectedIndex) else { guard self.optionItems.indices.contains(self.selectedIndex) else {
self.onStepSubmit(nil) self.onSubmit(nil)
return return
} }
let option = self.optionItems[self.selectedIndex].option let option = self.optionItems[self.selectedIndex].option
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
case "multiselect": case "multiselect":
let values = self.optionItems let values = self.optionItems
.filter { self.selectedIndices.contains($0.index) } .filter { self.selectedIndices.contains($0.index) }
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
self.onStepSubmit(AnyCodable(values)) self.onSubmit(AnyCodable(values))
case "action": case "action":
self.onStepSubmit(AnyCodable(true)) self.onSubmit(AnyCodable(true))
default: default:
self.onStepSubmit(nil) self.onSubmit(nil)
} }
} }
} }
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.20</string> <string>2026.1.11-4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601200</string> <string>202601113</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -2,9 +2,6 @@ import AppKit
import Foundation import Foundation
import Observation import Observation
import os import os
#if canImport(Darwin)
import Darwin
#endif
/// Manages Tailscale integration and status checking. /// Manages Tailscale integration and status checking.
@Observable @Observable
@@ -103,14 +100,16 @@ final class TailscaleService {
} }
func checkTailscaleStatus() async { func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation() self.isInstalled = self.checkAppInstallation()
if !self.isInstalled { guard self.isInstalled else {
self.isRunning = false self.isRunning = false
self.tailscaleHostname = nil self.tailscaleHostname = nil
self.tailscaleIP = nil self.tailscaleIP = nil
self.statusError = "Tailscale is not installed" self.statusError = "Tailscale is not installed"
} else if let apiResponse = await fetchTailscaleStatus() { return
}
if let apiResponse = await fetchTailscaleStatus() {
self.isRunning = apiResponse.status.lowercased() == "running" self.isRunning = apiResponse.status.lowercased() == "running"
if self.isRunning { if self.isRunning {
@@ -139,19 +138,6 @@ final class TailscaleService {
self.statusError = "Please start the Tailscale app" self.statusError = "Please start the Tailscale app"
self.logger.info("Tailscale API not responding; app likely not running") self.logger.info("Tailscale API not responding; app likely not running")
} }
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
self.tailscaleIP = fallback
if !self.isRunning {
self.isRunning = true
}
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
} }
func openTailscaleApp() { func openTailscaleApp() {
@@ -177,50 +163,4 @@ final class TailscaleService {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }
} }
private static func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
private static func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if Self.isTailnetIPv4(ip) { return ip }
}
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
Self.detectTailnetIPv4()
}
} }
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig( guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue } if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)" let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix) let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count)) ? String(ptrName.dropLast(suffix.count))
: ptrName : ptrName
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
{ {
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)" let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let ips = candidates let ips = candidates
candidates.removeAll(keepingCapacity: true) candidates.removeAll(keepingCapacity: true)
@@ -0,0 +1,150 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
@main
struct ClawdbotDiscoveryCLI {
static func main() async {
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
if opts.help {
print("""
clawdbot-mac-discovery
Usage:
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
}
+5 -4
View File
@@ -408,7 +408,8 @@ extension Request: Codable {
} }
// Shared transport settings // Shared transport settings
public let controlSocketPath = FileManager() public let controlSocketPath =
.homeDirectoryForCurrentUser FileManager()
.appendingPathComponent("Library/Application Support/clawdbot/control.sock") .homeDirectoryForCurrentUser
.path .appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path
@@ -1,359 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
#if canImport(Darwin)
import Darwin
#endif
struct ConnectOptions {
var url: String?
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
var clientMode: String = "ui"
var displayName: String?
var role: String = "operator"
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
var help: Bool = false
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0
while i < args.count {
let arg = args[i]
if let handler = flagHandlers[arg] {
handler(&opts)
i += 1
continue
}
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
}
i += 1
}
return opts
}
private static func nextValue(_ args: [String], index: inout Int) -> String? {
guard index + 1 < args.count else { return nil }
index += 1
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
struct ConnectOutput: Encodable {
var status: String
var url: String
var mode: String
var role: String
var clientId: String
var clientMode: String
var scopes: [String]
var snapshot: HelloOk?
var health: ProtoAnyCodable?
var error: String?
}
actor SnapshotStore {
private var value: HelloOk?
func set(_ snapshot: HelloOk) {
self.value = snapshot
}
func get() -> HelloOk? {
self.value
}
}
func runConnect(_ args: [String]) async {
let opts = ConnectOptions.parse(args)
if opts.help {
print("""
clawdbot-mac connect
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Resolve from config: local|remote (default: config or local)
--timeout <ms> Request timeout (default: 15000)
--probe Force a fresh health probe
--json Emit JSON
--client-id <id> Override client id (default: clawdbot-macos)
--client-mode <m> Override client mode (default: ui)
--display-name <n> Override display name
--role <role> Override role (default: operator)
--scopes <a,b,c> Override scopes list
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
let connectOptions = GatewayConnectOptions(
role: opts.role,
scopes: opts.scopes,
caps: [],
commands: [],
permissions: [:],
clientId: opts.clientId,
clientMode: opts.clientMode,
clientDisplayName: displayName)
let snapshotStore = SnapshotStore()
let channel = GatewayChannelActor(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
pushHandler: { push in
if case let .snapshot(ok) = push {
await snapshotStore.set(ok)
}
},
connectOptions: connectOptions)
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
let data = try await channel.request(
method: "health",
params: params,
timeoutMs: Double(opts.timeoutMs))
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
let snapshot = await snapshotStore.get()
await channel.shutdown()
let output = ConnectOutput(
status: "ok",
url: endpoint.url.absoluteString,
mode: endpoint.mode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: snapshot,
health: health,
error: nil)
printConnectOutput(output, json: opts.json)
} catch {
let endpoint = bestEffortEndpoint(opts: opts, config: config)
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
let output = ConnectOutput(
status: "error",
url: endpoint?.url.absoluteString ?? "unknown",
mode: endpoint?.mode ?? fallbackMode,
role: opts.role,
clientId: opts.clientId,
clientMode: opts.clientMode,
scopes: opts.scopes,
snapshot: nil,
health: nil,
error: error.localizedDescription)
printConnectOutput(output, json: opts.json)
exit(1)
}
}
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
if json {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(output),
let text = String(data: data, encoding: .utf8)
{
print(text)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Clawdbot macOS Gateway Connect")
print("Status: \(output.status)")
print("URL: \(output.url)")
print("Mode: \(output.mode)")
print("Client: \(output.clientId) (\(output.clientMode))")
print("Role: \(output.role)")
print("Scopes: \(output.scopes.joined(separator: ", "))")
if let snapshot = output.snapshot {
print("Protocol: \(snapshot._protocol)")
if let version = snapshot.server["version"]?.value as? String {
print("Server: \(version)")
}
}
if let health = output.health,
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
{
print("Health: \(ok ? "ok" : "error")")
} else if output.health != nil {
print("Health: received")
}
if let error = output.error {
print("Error: \(error)")
}
}
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind)
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
mode: resolvedMode)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
return token
}
if mode == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
return password
}
if mode == "remote" {
return config.remotePassword
}
return config.password
}
private func resolveLocalHost(bind: String?) -> String {
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tailnetIP = detectTailnetIPv4()
switch normalized {
case "tailnet", "auto":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}
private func detectTailnetIPv4() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
let family = ptr.pointee.ifa_addr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = ptr.pointee.ifa_addr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let len = buffer.prefix { $0 != 0 }
let bytes = len.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if isTailnetIPv4(ip) { return ip }
}
return nil
}
private func isTailnetIPv4(_ address: String) -> Bool {
let parts = address.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
return a == 100 && b >= 64 && b <= 127
}
@@ -1,149 +0,0 @@
import ClawdbotDiscovery
import Foundation
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
clawdbot-mac discover
Usage:
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}
@@ -1,56 +0,0 @@
import Foundation
private struct RootCommand {
var name: String
var args: [String]
}
@main
struct ClawdbotMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
let command = parseRootCommand(args)
switch command?.name {
case nil:
printUsage()
case "-h", "--help", "help":
printUsage()
case "connect":
await runConnect(command?.args ?? [])
case "discover":
await runDiscover(command?.args ?? [])
case "wizard":
await runWizardCommand(command?.args ?? [])
default:
fputs("clawdbot-mac: unknown command\n", stderr)
printUsage()
exit(1)
}
}
}
private func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
private func printUsage() {
print("""
clawdbot-mac
Usage:
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
clawdbot-mac connect
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
clawdbot-mac discover --timeout 3000 --json
clawdbot-mac wizard --mode local
""")
}
@@ -1,60 +0,0 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
@@ -1,5 +0,0 @@
import ClawdbotKit
import ClawdbotProtocol
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
typealias KitAnyCodable = ClawdbotKit.AnyCodable
@@ -20,7 +20,7 @@ public struct ConnectParams: Codable, Sendable {
public let permissions: [String: AnyCodable]? public let permissions: [String: AnyCodable]?
public let role: String? public let role: String?
public let scopes: [String]? public let scopes: [String]?
public let device: [String: AnyCodable]? public let device: [String: AnyCodable]
public let auth: [String: AnyCodable]? public let auth: [String: AnyCodable]?
public let locale: String? public let locale: String?
public let useragent: String? public let useragent: String?
@@ -34,7 +34,7 @@ public struct ConnectParams: Codable, Sendable {
permissions: [String: AnyCodable]?, permissions: [String: AnyCodable]?,
role: String?, role: String?,
scopes: [String]?, scopes: [String]?,
device: [String: AnyCodable]?, device: [String: AnyCodable],
auth: [String: AnyCodable]?, auth: [String: AnyCodable]?,
locale: String?, locale: String?,
useragent: String? useragent: String?
@@ -205,9 +205,6 @@ public struct PresenceEntry: Codable, Sendable {
public let tags: [String]? public let tags: [String]?
public let text: String? public let text: String?
public let ts: Int public let ts: Int
public let deviceid: String?
public let roles: [String]?
public let scopes: [String]?
public let instanceid: String? public let instanceid: String?
public init( public init(
@@ -223,9 +220,6 @@ public struct PresenceEntry: Codable, Sendable {
tags: [String]?, tags: [String]?,
text: String?, text: String?,
ts: Int, ts: Int,
deviceid: String?,
roles: [String]?,
scopes: [String]?,
instanceid: String? instanceid: String?
) { ) {
self.host = host self.host = host
@@ -240,9 +234,6 @@ public struct PresenceEntry: Codable, Sendable {
self.tags = tags self.tags = tags
self.text = text self.text = text
self.ts = ts self.ts = ts
self.deviceid = deviceid
self.roles = roles
self.scopes = scopes
self.instanceid = instanceid self.instanceid = instanceid
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -258,9 +249,6 @@ public struct PresenceEntry: Codable, Sendable {
case tags case tags
case text case text
case ts case ts
case deviceid = "deviceId"
case roles
case scopes
case instanceid = "instanceId" case instanceid = "instanceId"
} }
} }
@@ -473,7 +461,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +482,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +502,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +523,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -839,47 +823,35 @@ public struct SessionsListParams: Codable, Sendable {
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1340,9 +1312,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1351,9 +1320,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1361,9 +1327,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1372,9 +1335,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
@@ -1,4 +1,3 @@
import ClawdbotKit
import ClawdbotProtocol import ClawdbotProtocol
import Darwin import Darwin
import Foundation import Foundation
@@ -49,6 +48,17 @@ struct WizardCliOptions {
} }
} }
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
enum WizardCliError: Error, CustomStringConvertible { enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String) case invalidUrl(String)
case missingRemoteUrl case missingRemoteUrl
@@ -67,56 +77,68 @@ enum WizardCliError: Error, CustomStringConvertible {
} }
} }
func runWizardCommand(_ args: [String]) async { @main
let opts = WizardCliOptions.parse(args) struct ClawdbotWizardCLI {
if opts.help { static func main() async {
print(""" let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
clawdbot-mac wizard if opts.help {
printUsage()
return
}
Usage: let config = loadGatewayConfig()
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>] do {
[--mode <local|remote>] [--workspace <path>] [--json] guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
Options: }
--url <url> Gateway WebSocket URL (overrides config) let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
--token <token> Gateway token (if required) let client = GatewayWizardClient(
--password <pw> Gateway password (if required) url: endpoint.url,
--mode <mode> Wizard mode (local|remote). Default: local token: endpoint.token,
--workspace <path> Wizard workspace override password: endpoint.password,
--json Print raw wizard responses json: opts.json)
-h, --help Show help try await client.connect()
""") defer { Task { await client.close() } }
return try await runWizard(client: client, opts: opts)
} } catch {
fputs("wizard: \(error)\n", stderr)
let config = loadGatewayConfig() exit(1)
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
} }
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
} }
} }
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { private struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
}
private func printUsage() {
print("""
clawdbot-mac-wizard
Usage:
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
}
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty { if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: (config.mode ?? "local").lowercased())
} }
let mode = (config.mode ?? "local").lowercased() let mode = (config.mode ?? "local").lowercased()
@@ -128,8 +150,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: mode)
} }
let port = config.port ?? 18789 let port = config.port ?? 18789
@@ -140,8 +161,7 @@ private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: Gatewa
return GatewayEndpoint( return GatewayEndpoint(
url: url, url: url,
token: resolvedToken(opts: opts, config: config), token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config), password: resolvedPassword(opts: opts, config: config))
mode: mode)
} }
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
@@ -166,11 +186,48 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
return config.password return config.password
} }
actor GatewayWizardClient { private func loadGatewayConfig() -> GatewayConfig {
private enum ConnectChallengeError: Error { let url = FileManager().homeDirectoryForCurrentUser
case timeout .appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
} }
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
if let remote = gateway["remote"] as? [String: Any] {
cfg.remoteUrl = remote["url"] as? String
cfg.remoteToken = remote["token"] as? String
cfg.remotePassword = remote["password"] as? String
}
}
return cfg
}
private func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}
actor GatewayWizardClient {
private let url: URL private let url: URL
private let token: String? private let token: String?
private let password: String? private let password: String?
@@ -178,7 +235,6 @@ actor GatewayWizardClient {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default) private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask? private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) { init(url: URL, token: String?, password: String?, json: Bool) {
@@ -201,7 +257,7 @@ actor GatewayWizardClient {
self.task = nil self.task = nil
} }
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame { func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else { guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected") throw WizardCliError.gatewayError("gateway not connected")
} }
@@ -210,7 +266,7 @@ actor GatewayWizardClient {
type: "req", type: "req",
id: id, id: id,
method: method, method: method,
params: params.map { ProtoAnyCodable($0) }) params: params.map { AnyCodable($0) })
let data = try self.encoder.encode(frame) let data = try self.encoder.encode(frame)
try await task.send(.data(data)) try await task.send(.data(data))
@@ -253,66 +309,28 @@ actor GatewayWizardClient {
} }
let osVersion = ProcessInfo.processInfo.operatingSystemVersion let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let clientId = "clawdbot-macos" let client: [String: AnyCodable] = [
let clientMode = "ui" "id": AnyCodable("clawdbot-macos"),
let role = "operator" "displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
let scopes: [String] = [] "version": AnyCodable("dev"),
let client: [String: ProtoAnyCodable] = [ "platform": AnyCodable(platform),
"id": ProtoAnyCodable(clientId), "deviceFamily": AnyCodable("Mac"),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"), "mode": AnyCodable("ui"),
"version": ProtoAnyCodable("dev"), "instanceId": AnyCodable(UUID().uuidString),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
] ]
var params: [String: ProtoAnyCodable] = [ var params: [String: AnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client), "client": AnyCodable(client),
"caps": ProtoAnyCodable([String]()), "caps": AnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), "locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), "userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
] ]
if let token = self.token { if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) params["auth"] = AnyCodable(["token": AnyCodable(token)])
} else if let password = self.password { } else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) params["auth"] = AnyCodable(["password": AnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
} }
let reqId = UUID().uuidString let reqId = UUID().uuidString
@@ -320,58 +338,31 @@ actor GatewayWizardClient {
type: "req", type: "req",
id: reqId, id: reqId,
method: "connect", method: "connect",
params: ProtoAnyCodable(params)) params: AnyCodable(params))
let data = try self.encoder.encode(frame) let data = try self.encoder.encode(frame)
try await task.send(.data(data)) try await task.send(.data(data))
while true { let message = try await task.receive()
let message = try await task.receive() let frameResponse = try decodeFrame(message)
let frameResponse = try decodeFrame(message) guard case let .res(res) = frameResponse, res.id == reqId else {
if case let .res(res) = frameResponse, res.id == reqId { throw WizardCliError.gatewayError("connect failed (unexpected response)")
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
} }
} if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
private func waitForConnectChallenge() async throws -> String? { throw WizardCliError.gatewayError(msg)
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String
{
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
} }
_ = try self.decodePayload(res, as: HelloOk.self)
} }
} }
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: ProtoAnyCodable] = [:] var params: [String: AnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" { if mode == "local" || mode == "remote" {
params["mode"] = ProtoAnyCodable(mode) params["mode"] = AnyCodable(mode)
} }
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = ProtoAnyCodable(workspace) params["workspace"] = AnyCodable(workspace)
} }
let startResponse = try await client.request(method: "wizard.start", params: params) let startResponse = try await client.request(method: "wizard.start", params: params)
@@ -404,17 +395,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
if let step = decodeWizardStep(nextResult.step) { if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step) let answer = try promptAnswer(for: step)
var answerPayload: [String: ProtoAnyCodable] = [ var answerPayload: [String: AnyCodable] = [
"stepId": ProtoAnyCodable(step.id), "stepId": AnyCodable(step.id),
] ]
if !(answer is NSNull) { if !(answer is NSNull) {
answerPayload["value"] = ProtoAnyCodable(answer) answerPayload["value"] = AnyCodable(answer)
} }
let response = try await client.request( let response = try await client.request(
method: "wizard.next", method: "wizard.next",
params: [ params: [
"sessionId": ProtoAnyCodable(sessionId), "sessionId": AnyCodable(sessionId),
"answer": ProtoAnyCodable(answerPayload), "answer": AnyCodable(answerPayload),
]) ])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self) nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json { if opts.json {
@@ -423,7 +414,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} else { } else {
let response = try await client.request( let response = try await client.request(
method: "wizard.next", method: "wizard.next",
params: ["sessionId": ProtoAnyCodable(sessionId)]) params: ["sessionId": AnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self) nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json { if opts.json {
dumpResult(response) dumpResult(response)
@@ -433,7 +424,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
} catch WizardCliError.cancelled { } catch WizardCliError.cancelled {
_ = try? await client.request( _ = try? await client.request(
method: "wizard.cancel", method: "wizard.cancel",
params: ["sessionId": ProtoAnyCodable(sessionId)]) params: ["sessionId": AnyCodable(sessionId)])
throw WizardCliError.cancelled throw WizardCliError.cancelled
} }
} }
@@ -3,8 +3,6 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ChannelsSettingsSmokeTests { struct ChannelsSettingsSmokeTests {
@@ -19,11 +17,8 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
@@ -42,7 +37,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000, "lastEventAt": 1_700_000_060_000,
"lastError": "needs login", "lastError": "needs login",
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": true, "configured": true,
"tokenSource": "env", "tokenSource": "env",
"running": true, "running": true,
@@ -57,7 +52,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": true, "configured": true,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": true, "running": true,
@@ -70,7 +65,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -105,18 +100,15 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": SnapshotAnyCodable([ "whatsapp": AnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
]), ]),
"telegram": SnapshotAnyCodable([ "telegram": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "bot missing", "lastError": "bot missing",
@@ -128,7 +120,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_100_000, "lastProbeAt": 1_700_000_100_000,
]), ]),
"signal": SnapshotAnyCodable([ "signal": AnyCodable([
"configured": false, "configured": false,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": false, "running": false,
@@ -141,7 +133,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
"imessage": SnapshotAnyCodable([ "imessage": AnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -11,19 +11,16 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorBuildsBodyForNewJob() { @Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorBuildsBodyForExistingJob() { @Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops", agentId: "ops",
@@ -57,36 +54,31 @@ struct CronJobEditorSmokeTests {
job: job, job: job,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorExercisesBuilders() { @Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor( var view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
view.exerciseForTesting() view.exerciseForTesting()
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
var root: [String: Any] = [:] var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true) view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
@@ -11,7 +11,6 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false) #expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
} }
@@ -19,7 +18,6 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last) #expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last) #expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last) #expect(GatewayAgentChannel(raw: "unknown") == .last)
} }
} }
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,4 +1,3 @@
import ClawdbotKit
import Foundation import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@@ -139,40 +139,4 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote) #expect(resolved.mode == .remote)
} }
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
} }
@@ -7,17 +7,15 @@ import Testing
@Suite(.serialized) @Suite(.serialized)
struct LowCoverageHelperTests { struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws { @Test func anyCodableHelperAccessors() throws {
let payload: [String: ProtoAnyCodable] = [ let payload: [String: AnyCodable] = [
"title": ProtoAnyCodable("Hello"), "title": AnyCodable("Hello"),
"flag": ProtoAnyCodable(true), "flag": AnyCodable(true),
"count": ProtoAnyCodable(3), "count": AnyCodable(3),
"ratio": ProtoAnyCodable(1.25), "ratio": AnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]), "list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
] ]
let any = ProtoAnyCodable(payload) let any = AnyCodable(payload)
let dict = try #require(any.dictionaryValue) let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello") #expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true) #expect(dict["flag"]?.boolValue == true)
@@ -78,27 +76,31 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999")) #expect(result.stderr.contains("stderr-1999"))
} }
@Test func nodeInfoCodableRoundTrip() throws { @Test func pairedNodesStorePersists() async throws {
let info = NodeInfo( let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
nodeId: "node-1", nodeId: "node-1",
displayName: "Node One", displayName: "Node One",
platform: "macOS", platform: "macOS",
version: "1.0", version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: "MacBookPro", modelIdentifier: "MacBookPro",
remoteIp: "192.168.1.2", token: "token",
caps: ["chat"], createdAtMs: 1,
commands: ["send"], lastSeenAtMs: nil)
permissions: ["send": true], try await store.upsert(node)
paired: true, #expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
connected: false)
let data = try JSONEncoder().encode(info) try await store.touchSeen(nodeId: "node-1")
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data) let updated = await store.find(nodeId: "node-1")
#expect(decoded.nodeId == "node-1") #expect(updated?.lastSeenAtMs != nil)
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
} }
@Test @MainActor func presenceReporterHelpers() { @Test @MainActor func presenceReporterHelpers() {
@@ -21,7 +21,6 @@ import Testing
features: [:], features: [:],
snapshot: snapshot, snapshot: snapshot,
canvashosturl: nil, canvashosturl: nil,
auth: nil,
policy: [:]) policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,15 +3,13 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct OnboardingWizardStepViewTests { struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() { @Test func noteStepBuilds() {
let step = WizardStep( let step = WizardStep(
id: "step-1", id: "step-1",
type: ProtoAnyCodable("note"), type: AnyCodable("note"),
title: "Welcome", title: "Welcome",
message: "Hello", message: "Hello",
options: nil, options: nil,
@@ -24,17 +22,17 @@ struct OnboardingWizardStepViewTests {
} }
@Test func selectStepBuilds() { @Test func selectStepBuilds() {
let options: [[String: ProtoAnyCodable]] = [ let options: [[String: AnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], ["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], ["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
] ]
let step = WizardStep( let step = WizardStep(
id: "step-2", id: "step-2",
type: ProtoAnyCodable("select"), type: AnyCodable("select"),
title: "Mode", title: "Mode",
message: "Choose a mode", message: "Choose a mode",
options: options, options: options,
initialvalue: ProtoAnyCodable("local"), initialvalue: AnyCodable("local"),
placeholder: nil, placeholder: nil,
sensitive: nil, sensitive: nil,
executor: nil) executor: nil)
@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" { if recordType == "PTR" {
if nameserver == "@100.123.224.76" { if nameserver == "@100.123.224.76" {
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n" return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
} }
return "" return ""
} }
@@ -2,7 +2,7 @@ import Foundation
public enum ClawdbotBonjour { public enum ClawdbotBonjour {
// v0: internal-only, subject to rename. // v0: internal-only, subject to rename.
public static let gatewayServiceType = "_clawdbot-gw._tcp" public static let gatewayServiceType = "_clawdbot-gateway._tcp"
public static let gatewayServiceDomain = "local." public static let gatewayServiceDomain = "local."
public static let wideAreaGatewayServiceDomain = "clawdbot.internal." public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
@@ -1,107 +0,0 @@
import Foundation
public struct DeviceAuthEntry: Codable, Sendable {
public let token: String
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
self.token = token
self.role = role
self.scopes = scopes
self.updatedAtMs = updatedAtMs
}
}
private struct DeviceAuthStoreFile: Codable {
var version: Int
var deviceId: String
var tokens: [String: DeviceAuthEntry]
}
public enum DeviceAuthStore {
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role)
return store.tokens[role]
}
public static func storeToken(
deviceId: String,
role: String,
token: String,
scopes: [String] = []
) -> DeviceAuthEntry {
let normalizedRole = normalizeRole(role)
var next = readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
)
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
writeStore(store)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
role.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
let trimmed = scopes
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return Array(Set(trimmed)).sorted()
}
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
}
guard decoded.version == 1 else { return nil }
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
// best-effort only
}
}
}
@@ -1,18 +1,11 @@
import CryptoKit import CryptoKit
import Foundation import Foundation
public struct DeviceIdentity: Codable, Sendable { struct DeviceIdentity: Codable, Sendable {
public var deviceId: String var deviceId: String
public var publicKey: String var publicKey: String
public var privateKey: String var privateKey: String
public var createdAtMs: Int var createdAtMs: Int
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
self.deviceId = deviceId
self.publicKey = publicKey
self.privateKey = privateKey
self.createdAtMs = createdAtMs
}
} }
enum DeviceIdentityPaths { enum DeviceIdentityPaths {
@@ -34,10 +27,10 @@ enum DeviceIdentityPaths {
} }
} }
public enum DeviceIdentityStore { enum DeviceIdentityStore {
private static let fileName = "device.json" private static let fileName = "device.json"
public static func loadOrCreate() -> DeviceIdentity { static func loadOrCreate() -> DeviceIdentity {
let url = self.fileURL() let url = self.fileURL()
if let data = try? Data(contentsOf: url), if let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
@@ -51,7 +44,7 @@ public enum DeviceIdentityStore {
return identity return identity
} }
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? { static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil } guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
do { do {
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData) let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
@@ -83,7 +76,7 @@ public enum DeviceIdentityStore {
.replacingOccurrences(of: "=", with: "") .replacingOccurrences(of: "=", with: "")
} }
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
guard let data = Data(base64Encoded: identity.publicKey) else { return nil } guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
return self.base64UrlEncode(data) return self.base64UrlEncode(data)
} }
@@ -15,9 +15,6 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable { public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state } public var state: URLSessionTask.State { self.task.state }
@@ -97,10 +94,6 @@ public struct GatewayConnectOptions: Sendable {
// Avoid ambiguity with the app's own AnyCodable type. // Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
private enum ConnectChallengeError: Error {
case timeout
}
public actor GatewayChannelActor { public actor GatewayChannelActor {
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
private var task: WebSocketTaskBox? private var task: WebSocketTaskBox?
@@ -120,7 +113,6 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6 private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75
private var watchdogTask: Task<Void, Never>? private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>? private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000 private let defaultRequestTimeoutMs: Double = 15000
@@ -264,8 +256,6 @@ public actor GatewayChannelActor {
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId let clientId = options.clientId
let clientMode = options.clientMode let clientMode = options.clientMode
let role = options.role
let scopes = options.scopes
let reqId = UUID().uuidString let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [ var client: [String: ProtoAnyCodable] = [
@@ -288,8 +278,8 @@ public actor GatewayChannelActor {
"caps": ProtoAnyCodable(options.caps), "caps": ProtoAnyCodable(options.caps),
"locale": ProtoAnyCodable(primaryLocale), "locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role), "role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(scopes), "scopes": ProtoAnyCodable(options.scopes),
] ]
if !options.commands.isEmpty { if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands) params["commands"] = ProtoAnyCodable(options.commands)
@@ -297,44 +287,32 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty { if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions) params["permissions"] = ProtoAnyCodable(options.permissions)
} }
let identity = DeviceIdentityStore.loadOrCreate() if let token = self.token {
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
let authToken = storedToken ?? self.token
let canFallbackToShared = storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password { } else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
} }
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let connectNonce = try await self.waitForConnectChallenge() let scopes = options.scopes.joined(separator: ",")
let scopesValue = scopes.joined(separator: ",") let payload = [
var payloadParts = [ "v1",
connectNonce == nil ? "v1" : "v2",
identity.deviceId, identity.deviceId,
clientId, clientId,
clientMode, clientMode,
role, options.role,
scopesValue, scopes,
String(signedAtMs), String(signedAtMs),
authToken ?? "", self.token ?? "",
] ].joined(separator: "|")
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [ params["device"] = ProtoAnyCodable([
"id": ProtoAnyCodable(identity.deviceId), "id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey), "publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature), "signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs), "signedAt": ProtoAnyCodable(signedAtMs),
] ])
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
} }
let frame = RequestFrame( let frame = RequestFrame(
@@ -344,22 +322,40 @@ public actor GatewayChannelActor {
params: ProtoAnyCodable(params)) params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame) let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data)) try await self.task?.send(.data(data))
do { guard let msg = try await task?.receive() else {
let response = try await self.waitForConnectResponse(reqId: reqId) throw NSError(
try await self.handleConnectResponse(response, identity: identity, role: role) domain: "Gateway",
} catch { code: 1,
if canFallbackToShared { userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
} }
try await self.handleConnectResponse(msg, reqId: reqId)
} }
private func handleConnectResponse( private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
_ res: ResponseFrame, let data: Data? = switch msg {
identity: DeviceIdentity, case let .data(d): d
role: String case let .string(s): s.data(using: .utf8)
) async throws { @unknown default: nil
}
guard let data else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
if res.ok == false { if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
@@ -377,17 +373,6 @@ public actor GatewayChannelActor {
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int { } else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick) self.tickIntervalMs = Double(tick)
} }
if let auth = ok.auth,
let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
self.lastTick = Date() self.lastTick = Date()
self.tickTask?.cancel() self.tickTask?.cancel()
self.tickTask = Task { [weak self] in self.tickTask = Task { [weak self] in
@@ -439,7 +424,6 @@ public actor GatewayChannelActor {
waiter.resume(returning: .res(res)) waiter.resume(returning: .res(res))
} }
case let .event(evt): case let .event(evt):
if evt.event == "connect.challenge" { return }
if let seq = evt.seq { if let seq = evt.seq {
if let last = lastSeq, seq > last + 1 { if let last = lastSeq, seq > last + 1 {
await self.pushHandler?(.seqGap(expected: last + 1, received: seq)) await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
@@ -453,63 +437,6 @@ public actor GatewayChannelActor {
} }
} }
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: { [weak self] in
guard let self else { return nil }
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge" {
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = payload["nonce"]?.value as? String {
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {
guard let task = self.task else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
while true {
let msg = try await task.receive()
guard let data = self.decodeMessageData(msg) else { continue }
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
if case let .res(res) = frame, res.id == reqId {
return res
}
}
}
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg {
case let .data(data): data
case let .string(text): text.data(using: .utf8)
@unknown default: nil
}
return data
}
private func watchTicks() async { private func watchTicks() async {
let tolerance = self.tickIntervalMs * 2 let tolerance = self.tickIntervalMs * 2
while self.connected { while self.connected {
@@ -29,10 +29,5 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
public let method: String public let method: String
public let message: String public let message: String
public init(method: String, message: String) {
self.method = method
self.message = message
}
public var errorDescription: String? { "\(self.method): \(self.message)" } public var errorDescription: String? { "\(self.method): \(self.message)" }
} }
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
var idempotencyKey: String? var idempotencyKey: String?
} }
// Ensures the timeout can win even if the invoke task never completes.
private actor InvokeTimeoutRace {
private var finished = false
private let continuation: CheckedContinuation<BridgeInvokeResponse, Never>
private var invokeTask: Task<Void, Never>?
private var timeoutTask: Task<Void, Never>?
init(continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
self.continuation = continuation
}
func registerTasks(invoke: Task<Void, Never>, timeout: Task<Void, Never>) {
self.invokeTask = invoke
self.timeoutTask = timeout
if finished {
invoke.cancel()
timeout.cancel()
}
}
func finish(_ response: BridgeInvokeResponse) {
guard !finished else { return }
finished = true
continuation.resume(returning: response)
invokeTask?.cancel()
timeoutTask?.cancel()
}
}
public actor GatewayNodeSession { public actor GatewayNodeSession {
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway") private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
@@ -52,45 +23,6 @@ public actor GatewayNodeSession {
private var onConnected: (@Sendable () async -> Void)? private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
guard timeout > 0 else {
return await onInvoke(request)
}
let cappedTimeout = min(timeout, Int(UInt64.max / 1_000_000))
let timeoutResponse = BridgeInvokeResponse(
id: request.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
return await withCheckedContinuation { continuation in
let race = InvokeTimeoutRace(continuation: continuation)
let invokeTask = Task {
let response = await onInvoke(request)
await race.finish(response)
}
let timeoutTask = Task {
do {
try await Task.sleep(nanoseconds: UInt64(cappedTimeout) * 1_000_000)
} catch {
return
}
await race.finish(timeoutResponse)
}
Task {
await race.registerTasks(invoke: invokeTask, timeout: timeoutTask)
}
}
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:] private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String? private var canvasHostUrl: String?
@@ -235,11 +167,7 @@ public actor GatewayNodeSession {
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return } guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await Self.invokeWithTimeout( let response = await onInvoke(req)
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
await self.sendInvokeResult(request: request, response: response) await self.sendInvokeResult(request: request, response: response)
} catch { } catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -252,10 +180,8 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id), "id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId), "nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok), "ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
] ]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let error = response.error { if let error = response.error {
params["error"] = AnyCodable([ params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue), "code": AnyCodable(error.code.rawValue),
@@ -36,7 +36,7 @@ public enum GatewayTLSStore {
} }
} }
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable { public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
private let params: GatewayTLSParams private let params: GatewayTLSParams
private lazy var session: URLSession = { private lazy var session: URLSession = {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
@@ -96,12 +96,10 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
} }
private func certificateFingerprint(_ trust: SecTrust) -> String? { private func certificateFingerprint(_ trust: SecTrust) -> String? {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let count = SecTrustGetCertificateCount(trust)
let cert = chain.first guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
else { let data = SecCertificateCopyData(cert) as Data
return nil return sha256Hex(data)
}
return sha256Hex(SecCertificateCopyData(cert) as Data)
} }
private func sha256Hex(_ data: Data) -> String { private func sha256Hex(_ data: Data) -> String {
@@ -110,9 +108,5 @@ private func sha256Hex(_ data: Data) -> String {
} }
private func normalizeFingerprint(_ raw: String) -> String { private func normalizeFingerprint(_ raw: String) -> String {
let stripped = raw.replacingOccurrences( raw.lowercased().filter(\.isHexDigit)
of: #"(?i)^sha-?256\s*:?\s*"#,
with: "",
options: .regularExpression)
return stripped.lowercased().filter(\.isHexDigit)
} }
@@ -5,7 +5,6 @@ public let GATEWAY_PROTOCOL_VERSION = 3
public enum ErrorCode: String, Codable, Sendable { public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED" case notLinked = "NOT_LINKED"
case notPaired = "NOT_PAIRED"
case agentTimeout = "AGENT_TIMEOUT" case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST" case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE" case unavailable = "UNAVAILABLE"
@@ -16,11 +15,6 @@ public struct ConnectParams: Codable, Sendable {
public let maxprotocol: Int public let maxprotocol: Int
public let client: [String: AnyCodable] public let client: [String: AnyCodable]
public let caps: [String]? public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
public let auth: [String: AnyCodable]? public let auth: [String: AnyCodable]?
public let locale: String? public let locale: String?
public let useragent: String? public let useragent: String?
@@ -30,11 +24,6 @@ public struct ConnectParams: Codable, Sendable {
maxprotocol: Int, maxprotocol: Int,
client: [String: AnyCodable], client: [String: AnyCodable],
caps: [String]?, caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
auth: [String: AnyCodable]?, auth: [String: AnyCodable]?,
locale: String?, locale: String?,
useragent: String? useragent: String?
@@ -43,11 +32,6 @@ public struct ConnectParams: Codable, Sendable {
self.maxprotocol = maxprotocol self.maxprotocol = maxprotocol
self.client = client self.client = client
self.caps = caps self.caps = caps
self.commands = commands
self.permissions = permissions
self.role = role
self.scopes = scopes
self.device = device
self.auth = auth self.auth = auth
self.locale = locale self.locale = locale
self.useragent = useragent self.useragent = useragent
@@ -57,11 +41,6 @@ public struct ConnectParams: Codable, Sendable {
case maxprotocol = "maxProtocol" case maxprotocol = "maxProtocol"
case client case client
case caps case caps
case commands
case permissions
case role
case scopes
case device
case auth case auth
case locale case locale
case useragent = "userAgent" case useragent = "userAgent"
@@ -75,7 +54,6 @@ public struct HelloOk: Codable, Sendable {
public let features: [String: AnyCodable] public let features: [String: AnyCodable]
public let snapshot: Snapshot public let snapshot: Snapshot
public let canvashosturl: String? public let canvashosturl: String?
public let auth: [String: AnyCodable]?
public let policy: [String: AnyCodable] public let policy: [String: AnyCodable]
public init( public init(
@@ -85,7 +63,6 @@ public struct HelloOk: Codable, Sendable {
features: [String: AnyCodable], features: [String: AnyCodable],
snapshot: Snapshot, snapshot: Snapshot,
canvashosturl: String?, canvashosturl: String?,
auth: [String: AnyCodable]?,
policy: [String: AnyCodable] policy: [String: AnyCodable]
) { ) {
self.type = type self.type = type
@@ -94,7 +71,6 @@ public struct HelloOk: Codable, Sendable {
self.features = features self.features = features
self.snapshot = snapshot self.snapshot = snapshot
self.canvashosturl = canvashosturl self.canvashosturl = canvashosturl
self.auth = auth
self.policy = policy self.policy = policy
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -104,7 +80,6 @@ public struct HelloOk: Codable, Sendable {
case features case features
case snapshot case snapshot
case canvashosturl = "canvasHostUrl" case canvashosturl = "canvasHostUrl"
case auth
case policy case policy
} }
} }
@@ -205,9 +180,6 @@ public struct PresenceEntry: Codable, Sendable {
public let tags: [String]? public let tags: [String]?
public let text: String? public let text: String?
public let ts: Int public let ts: Int
public let deviceid: String?
public let roles: [String]?
public let scopes: [String]?
public let instanceid: String? public let instanceid: String?
public init( public init(
@@ -223,9 +195,6 @@ public struct PresenceEntry: Codable, Sendable {
tags: [String]?, tags: [String]?,
text: String?, text: String?,
ts: Int, ts: Int,
deviceid: String?,
roles: [String]?,
scopes: [String]?,
instanceid: String? instanceid: String?
) { ) {
self.host = host self.host = host
@@ -240,9 +209,6 @@ public struct PresenceEntry: Codable, Sendable {
self.tags = tags self.tags = tags
self.text = text self.text = text
self.ts = ts self.ts = ts
self.deviceid = deviceid
self.roles = roles
self.scopes = scopes
self.instanceid = instanceid self.instanceid = instanceid
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@@ -258,9 +224,6 @@ public struct PresenceEntry: Codable, Sendable {
case tags case tags
case text case text
case ts case ts
case deviceid = "deviceId"
case roles
case scopes
case instanceid = "instanceId" case instanceid = "instanceId"
} }
} }
@@ -473,7 +436,6 @@ public struct AgentParams: Codable, Sendable {
public let replychannel: String? public let replychannel: String?
public let accountid: String? public let accountid: String?
public let replyaccountid: String? public let replyaccountid: String?
public let threadid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -495,7 +457,6 @@ public struct AgentParams: Codable, Sendable {
replychannel: String?, replychannel: String?,
accountid: String?, accountid: String?,
replyaccountid: String?, replyaccountid: String?,
threadid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -516,7 +477,6 @@ public struct AgentParams: Codable, Sendable {
self.replychannel = replychannel self.replychannel = replychannel
self.accountid = accountid self.accountid = accountid
self.replyaccountid = replyaccountid self.replyaccountid = replyaccountid
self.threadid = threadid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -538,7 +498,6 @@ public struct AgentParams: Codable, Sendable {
case replychannel = "replyChannel" case replychannel = "replyChannel"
case accountid = "accountId" case accountid = "accountId"
case replyaccountid = "replyAccountId" case replyaccountid = "replyAccountId"
case threadid = "threadId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -747,139 +706,40 @@ public struct NodeInvokeParams: Codable, Sendable {
} }
} }
public struct NodeInvokeResultParams: Codable, Sendable {
public let id: String
public let nodeid: String
public let ok: Bool
public let payload: AnyCodable?
public let payloadjson: String?
public let error: [String: AnyCodable]?
public init(
id: String,
nodeid: String,
ok: Bool,
payload: AnyCodable?,
payloadjson: String?,
error: [String: AnyCodable]?
) {
self.id = id
self.nodeid = nodeid
self.ok = ok
self.payload = payload
self.payloadjson = payloadjson
self.error = error
}
private enum CodingKeys: String, CodingKey {
case id
case nodeid = "nodeId"
case ok
case payload
case payloadjson = "payloadJSON"
case error
}
}
public struct NodeEventParams: Codable, Sendable {
public let event: String
public let payload: AnyCodable?
public let payloadjson: String?
public init(
event: String,
payload: AnyCodable?,
payloadjson: String?
) {
self.event = event
self.payload = payload
self.payloadjson = payloadjson
}
private enum CodingKeys: String, CodingKey {
case event
case payload
case payloadjson = "payloadJSON"
}
}
public struct NodeInvokeRequestEvent: Codable, Sendable {
public let id: String
public let nodeid: String
public let command: String
public let paramsjson: String?
public let timeoutms: Int?
public let idempotencykey: String?
public init(
id: String,
nodeid: String,
command: String,
paramsjson: String?,
timeoutms: Int?,
idempotencykey: String?
) {
self.id = id
self.nodeid = nodeid
self.command = command
self.paramsjson = paramsjson
self.timeoutms = timeoutms
self.idempotencykey = idempotencykey
}
private enum CodingKeys: String, CodingKey {
case id
case nodeid = "nodeId"
case command
case paramsjson = "paramsJSON"
case timeoutms = "timeoutMs"
case idempotencykey = "idempotencyKey"
}
}
public struct SessionsListParams: Codable, Sendable { public struct SessionsListParams: Codable, Sendable {
public let limit: Int? public let limit: Int?
public let activeminutes: Int? public let activeminutes: Int?
public let includeglobal: Bool? public let includeglobal: Bool?
public let includeunknown: Bool? public let includeunknown: Bool?
public let includederivedtitles: Bool?
public let includelastmessage: Bool?
public let label: String? public let label: String?
public let spawnedby: String? public let spawnedby: String?
public let agentid: String? public let agentid: String?
public let search: String?
public init( public init(
limit: Int?, limit: Int?,
activeminutes: Int?, activeminutes: Int?,
includeglobal: Bool?, includeglobal: Bool?,
includeunknown: Bool?, includeunknown: Bool?,
includederivedtitles: Bool?,
includelastmessage: Bool?,
label: String?, label: String?,
spawnedby: String?, spawnedby: String?,
agentid: String?, agentid: String?
search: String?
) { ) {
self.limit = limit self.limit = limit
self.activeminutes = activeminutes self.activeminutes = activeminutes
self.includeglobal = includeglobal self.includeglobal = includeglobal
self.includeunknown = includeunknown self.includeunknown = includeunknown
self.includederivedtitles = includederivedtitles
self.includelastmessage = includelastmessage
self.label = label self.label = label
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.agentid = agentid self.agentid = agentid
self.search = search
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case limit case limit
case activeminutes = "activeMinutes" case activeminutes = "activeMinutes"
case includeglobal = "includeGlobal" case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown" case includeunknown = "includeUnknown"
case includederivedtitles = "includeDerivedTitles"
case includelastmessage = "includeLastMessage"
case label case label
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case agentid = "agentId" case agentid = "agentId"
case search
} }
} }
@@ -1340,9 +1200,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let ts: Int public let ts: Int
public let channelorder: [String] public let channelorder: [String]
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1351,9 +1208,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
ts: Int, ts: Int,
channelorder: [String], channelorder: [String],
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1361,9 +1215,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.ts = ts self.ts = ts
self.channelorder = channelorder self.channelorder = channelorder
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1372,9 +1223,6 @@ public struct ChannelsStatusResult: Codable, Sendable {
case ts case ts
case channelorder = "channelOrder" case channelorder = "channelOrder"
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"
@@ -1533,22 +1381,6 @@ public struct ModelsListResult: Codable, Sendable {
public struct SkillsStatusParams: Codable, Sendable { public struct SkillsStatusParams: Codable, Sendable {
} }
public struct SkillsBinsParams: Codable, Sendable {
}
public struct SkillsBinsResult: Codable, Sendable {
public let bins: [String]
public init(
bins: [String]
) {
self.bins = bins
}
private enum CodingKeys: String, CodingKey {
case bins
}
}
public struct SkillsInstallParams: Codable, Sendable { public struct SkillsInstallParams: Codable, Sendable {
public let name: String public let name: String
public let installid: String public let installid: String
@@ -1903,225 +1735,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
} }
} }
public struct ExecApprovalRequestParams: Codable, Sendable {
public let command: String
public let cwd: String?
public let host: String?
public let security: String?
public let ask: String?
public let agentid: String?
public let resolvedpath: String?
public let sessionkey: String?
public let timeoutms: Int?
public init(
command: String,
cwd: String?,
host: String?,
security: String?,
ask: String?,
agentid: String?,
resolvedpath: String?,
sessionkey: String?,
timeoutms: Int?
) {
self.command = command
self.cwd = cwd
self.host = host
self.security = security
self.ask = ask
self.agentid = agentid
self.resolvedpath = resolvedpath
self.sessionkey = sessionkey
self.timeoutms = timeoutms
}
private enum CodingKeys: String, CodingKey {
case command
case cwd
case host
case security
case ask
case agentid = "agentId"
case resolvedpath = "resolvedPath"
case sessionkey = "sessionKey"
case timeoutms = "timeoutMs"
}
}
public struct ExecApprovalResolveParams: Codable, Sendable {
public let id: String
public let decision: String
public init(
id: String,
decision: String
) {
self.id = id
self.decision = decision
}
private enum CodingKeys: String, CodingKey {
case id
case decision
}
}
public struct DevicePairListParams: Codable, Sendable {
}
public struct DevicePairApproveParams: Codable, Sendable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct DevicePairRejectParams: Codable, Sendable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct DeviceTokenRotateParams: Codable, Sendable {
public let deviceid: String
public let role: String
public let scopes: [String]?
public init(
deviceid: String,
role: String,
scopes: [String]?
) {
self.deviceid = deviceid
self.role = role
self.scopes = scopes
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
case scopes
}
}
public struct DeviceTokenRevokeParams: Codable, Sendable {
public let deviceid: String
public let role: String
public init(
deviceid: String,
role: String
) {
self.deviceid = deviceid
self.role = role
}
private enum CodingKeys: String, CodingKey {
case deviceid = "deviceId"
case role
}
}
public struct DevicePairRequestedEvent: Codable, Sendable {
public let requestid: String
public let deviceid: String
public let publickey: String
public let displayname: String?
public let platform: String?
public let clientid: String?
public let clientmode: String?
public let role: String?
public let roles: [String]?
public let scopes: [String]?
public let remoteip: String?
public let silent: Bool?
public let isrepair: Bool?
public let ts: Int
public init(
requestid: String,
deviceid: String,
publickey: String,
displayname: String?,
platform: String?,
clientid: String?,
clientmode: String?,
role: String?,
roles: [String]?,
scopes: [String]?,
remoteip: String?,
silent: Bool?,
isrepair: Bool?,
ts: Int
) {
self.requestid = requestid
self.deviceid = deviceid
self.publickey = publickey
self.displayname = displayname
self.platform = platform
self.clientid = clientid
self.clientmode = clientmode
self.role = role
self.roles = roles
self.scopes = scopes
self.remoteip = remoteip
self.silent = silent
self.isrepair = isrepair
self.ts = ts
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case deviceid = "deviceId"
case publickey = "publicKey"
case displayname = "displayName"
case platform
case clientid = "clientId"
case clientmode = "clientMode"
case role
case roles
case scopes
case remoteip = "remoteIp"
case silent
case isrepair = "isRepair"
case ts
}
}
public struct DevicePairResolvedEvent: Codable, Sendable {
public let requestid: String
public let deviceid: String
public let decision: String
public let ts: Int
public init(
requestid: String,
deviceid: String,
decision: String,
ts: Int
) {
self.requestid = requestid
self.deviceid = deviceid
self.decision = decision
self.ts = ts
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case deviceid = "deviceId"
case decision
case ts
}
}
public struct ChatHistoryParams: Codable, Sendable { public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String public let sessionkey: String
public let limit: Int? public let limit: Int?
@@ -1,78 +0,0 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
struct GatewayNodeSessionTests {
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 50,
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
#expect(response.payloadJSON == "{}")
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message.contains("timed out") == true)
}
@Test
func invokeWithTimeoutReturnsWhenHandlerNeverCompletes() async {
let request = BridgeInvokeRequest(id: "stall", command: "x", paramsJSON: nil)
let response = try? await AsyncTimeout.withTimeoutMs(
timeoutMs: 200,
onTimeout: { NSError(domain: "GatewayNodeSessionTests", code: 1) },
operation: {
await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
await withCheckedContinuation { _ in }
}
)
}
)
#expect(response != nil)
#expect(response?.ok == false)
#expect(response?.error?.code == .unavailable)
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 0,
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
}
-5
View File
@@ -127,11 +127,6 @@ Isolated jobs can deliver output to a channel. The job payload can specify:
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route” If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
(the last place the agent replied). (the last place the agent replied).
Delivery notes:
- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted.
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
- Use `deliver: false` to keep output internal even if a `to` is present.
Target format reminders: Target format reminders:
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity. - Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Telegram topics should use the `:topic:` form (see below). - Telegram topics should use the `:topic:` form (see below).
+26 -178
View File
@@ -1,203 +1,55 @@
--- ---
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)." summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
read_when: read_when:
- Setting up BlueBubbles channel - Setting up BlueBubbles channel
- Troubleshooting webhook pairing - Troubleshooting webhook pairing
- Configuring iMessage on macOS
--- ---
# BlueBubbles (macOS REST) # BlueBubbles (macOS REST)
Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel. Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
## Overview ## Overview
- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)). - Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync.
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`). - Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls. - Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible). - Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes. - Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying. - Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
- Advanced features: edit, unsend, reply threading, message effects, group management.
## Quick start ## Quick start
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). 1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
2. In the BlueBubbles config, enable the web API and set a password. 2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
3. Run `clawdbot onboard` and select BlueBubbles, or configure manually: 3. Configure Clawdbot:
```json5 ```json5
{ {
channels: { channels: {
bluebubbles: { bluebubbles: {
enabled: true, enabled: true,
serverUrl: "http://192.168.1.100:1234", serverUrl: "http://bluebubbles-host:1234",
password: "example-password", password: "example-password",
webhookPath: "/bluebubbles-webhook" webhookPath: "/bluebubbles-webhook",
actions: { reactions: true }
} }
} }
} }
``` ```
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`). 4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing. 5. Start the gateway; it will register the webhook handler and start pairing.
## Onboarding ## Configuration notes
BlueBubbles is available in the interactive setup wizard: - `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
``` - `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
clawdbot onboard - `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
``` - `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
The wizard prompts for: ## How it works
- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`) - Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
- **Password** (required): API password from BlueBubbles Server settings - Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
- **Webhook path** (optional): Defaults to `/bluebubbles-webhook` - Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
- **DM policy**: pairing, allowlist, open, or disabled - Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
- **Allow list**: Phone numbers, emails, or chat targets
You can also add BlueBubbles via CLI:
```
clawdbot channels add bluebubbles --http-url http://192.168.1.100:1234 --password <password>
```
## Access control (DMs + groups)
DMs:
- Default: `channels.bluebubbles.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `clawdbot pairing list bluebubbles`
- `clawdbot pairing approve bluebubbles <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
Groups:
- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
### Mention gating (groups)
BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior:
- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions.
- When `requireMention` is enabled for a group, the agent only responds when mentioned.
- Control commands from authorized senders bypass mention gating.
Per-group configuration:
```json5
{
channels: {
bluebubbles: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"*": { requireMention: true }, // default for all groups
"iMessage;-;chat123": { requireMention: false } // override for specific group
}
}
}
}
```
### Command gating
- Control commands (e.g., `/config`, `/model`) require authorization.
- Uses `allowFrom` and `groupAllowFrom` to determine command authorization.
- Authorized senders can run control commands even without mentioning in groups.
## Typing + read receipts
- **Typing indicators**: Sent automatically before and during response generation.
- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`).
- **Typing indicators**: Clawdbot sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable).
```json5
{
channels: {
bluebubbles: {
sendReadReceipts: false // disable read receipts
}
}
}
```
## Advanced actions
BlueBubbles supports advanced message actions when enabled in config:
```json5
{
channels: {
bluebubbles: {
actions: {
reactions: true, // tapbacks (default: true)
edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe)
unsend: true, // unsend messages (macOS 13+)
reply: true, // reply threading by message GUID
sendWithEffect: true, // message effects (slam, loud, etc.)
renameGroup: true, // rename group chats
setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe)
addParticipant: true, // add participants to groups
removeParticipant: true, // remove participants from groups
leaveGroup: true, // leave group chats
sendAttachment: true // send attachments/media
}
}
}
}
```
Available actions:
- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`)
- **edit**: Edit a sent message (`messageId`, `text`)
- **unsend**: Unsend a message (`messageId`)
- **reply**: Reply to a specific message (`messageId`, `text`, `to`)
- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`)
- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`)
- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync).
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
## Block streaming
Control whether responses are sent as a single message or streamed in blocks:
```json5
{
channels: {
bluebubbles: {
blockStreaming: true // enable block streaming (default behavior)
}
}
}
```
## Media + limits
- Inbound attachments are downloaded and stored in the media cache.
- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB).
- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars).
## Configuration reference
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.bluebubbles.enabled`: Enable/disable the channel.
- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL.
- `channels.bluebubbles.password`: API password.
- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`).
- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`).
- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`).
- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`).
- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist.
- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.).
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
- `channels.bluebubbles.actions`: Enable/disable specific actions.
- `channels.bluebubbles.accounts`: Multi-account configuration.
Related global options:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
- `messages.responsePrefix`.
## Addressing / delivery targets
Prefer `chat_guid` for stable routing:
- `chat_guid:iMessage;-;+15555550123` (preferred for groups)
- `chat_id:123`
- `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com`
## Security ## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. - Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
@@ -205,12 +57,8 @@ Prefer `chat_guid` for stable routing:
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN. - Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
## Troubleshooting ## Troubleshooting
- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. - If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`. - Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. - Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes.
- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync.
- Clawdbot auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
- For status/health info: `clawdbot status --all` or `clawdbot status --deep`.
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide. For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
+2 -4
View File
@@ -16,12 +16,10 @@ Text is supported everywhere; media and reactions vary by channel.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [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.
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage](/channels/imessage) — macOS only; native integration.
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups). - [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (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). - [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.
-2
View File
@@ -14,7 +14,6 @@ Clawdbot normalizes shared locations from chat channels into:
Currently supported: Currently supported:
- **Telegram** (location pins + venues + live locations) - **Telegram** (location pins + venues + live locations)
- **WhatsApp** (locationMessage + liveLocationMessage) - **WhatsApp** (locationMessage + liveLocationMessage)
- **Matrix** (`m.location` with `geo_uri`)
## Text formatting ## Text formatting
Locations are rendered as friendly lines without brackets: Locations are rendered as friendly lines without brackets:
@@ -45,4 +44,3 @@ When a location is present, these fields are added to `ctx`:
## Channel notes ## Channel notes
- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. - **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`.
- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. - **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line.
- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false.
+25 -122
View File
@@ -5,26 +5,17 @@ read_when:
--- ---
# Matrix (plugin) # Matrix (plugin)
Matrix is an open, decentralized messaging protocol. Clawdbot connects as a Matrix **user** Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls.
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required ## Plugin required
Matrix ships as a plugin and is not bundled with the core install. Matrix ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry): Install via CLI (npm registry):
```bash ```bash
clawdbot plugins install @clawdbot/matrix clawdbot plugins install @clawdbot/matrix
``` ```
Local checkout (when running from a git repo): Local checkout (when running from a git repo):
```bash ```bash
clawdbot plugins install ./extensions/matrix clawdbot plugins install ./extensions/matrix
``` ```
@@ -34,54 +25,27 @@ Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin) Details: [Plugins](/plugin)
## Setup ## Quick setup (beginner)
1) Install the Matrix plugin: 1) Install the Matrix plugin:
- From npm: `clawdbot plugins install @clawdbot/matrix` - From npm: `clawdbot plugins install @clawdbot/matrix`
- From a local checkout: `clawdbot plugins install ./extensions/matrix` - From a local checkout: `clawdbot plugins install ./extensions/matrix`
2) Create a Matrix account on a homeserver: 2) Configure credentials:
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/) - Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
- Or host it yourself.
3) Get an access token for the bot account:
- Use the Matrix login API with `curl` at your home server:
```bash
curl --request POST \
--url https://matrix.example.org/_matrix/client/v3/login \
--header 'Content-Type: application/json' \
--data '{
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": "your-user-name"
},
"password": "your-password"
}'
```
- Replace `matrix.example.org` with your homeserver URL.
- Or set `channels.matrix.userId` + `channels.matrix.password`: Clawdbot calls the same
login endpoint, stores the access token in `~/.clawdbot/credentials/matrix/credentials.json`,
and reuses it on next start.
4) Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*` - Or config: `channels.matrix.*`
- If both are set, config takes precedence. - If both are set, config takes precedence.
- With access token: user ID is fetched automatically via `/whoami`. 3) Restart the gateway (or finish onboarding).
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). 4) DM access defaults to pairing; approve the pairing code on first contact.
5) Restart the gateway (or finish onboarding).
6) Start a DM with the bot or invite it to a room from any Matrix client
(Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE,
so set `channels.matrix.encryption: true` and verify the device.
Minimal config (access token, user ID auto-fetched): Runtime note: Matrix requires Node.js (Bun is not supported).
Minimal config:
```json5 ```json5
{ {
channels: { channels: {
matrix: { matrix: {
enabled: true, enabled: true,
homeserver: "https://matrix.example.org", homeserver: "https://matrix.example.org",
userId: "@clawdbot:example.org",
accessToken: "syt_***", accessToken: "syt_***",
dm: { policy: "pairing" } dm: { policy: "pairing" }
} }
@@ -89,57 +53,18 @@ Minimal config (access token, user ID auto-fetched):
} }
``` ```
E2EE config (end to end encryption enabled):
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
encryption: true,
dm: { policy: "pairing" }
}
}
}
```
## Encryption (E2EE) ## Encryption (E2EE)
End-to-end encrypted rooms are **not** supported.
- Use unencrypted rooms or disable encryption when creating the room.
- If a room is E2EE, the bot will receive encrypted events and won’t reply.
End-to-end encryption is **supported** via the Rust crypto SDK. ## What it is
Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms.
Enable with `channels.matrix.encryption: true`: - A Matrix user account owned by the Gateway.
- Deterministic routing: replies go back to Matrix.
- If the crypto module loads, encrypted rooms are decrypted automatically.
- Outbound media is encrypted when sending to encrypted rooms.
- On first connection, Clawdbot requests device verification from your other sessions.
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
Clawdbot logs a warning.
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
Crypto state is stored per account + access token in
`~/.clawdbot/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
If the access token (device) changes, a new store is created and the bot must be
re-verified for encrypted rooms.
**Device verification:**
When E2EE is enabled, the bot will request verification from your other sessions on startup.
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
## Routing model
- Replies always go back to Matrix.
- DMs share the agent's main session; rooms map to group sessions. - DMs share the agent's main session; rooms map to group sessions.
## Access control (DMs) ## Access control (DMs)
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code. - Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
- Approve via: - Approve via:
- `clawdbot pairing list matrix` - `clawdbot pairing list matrix`
@@ -148,80 +73,58 @@ Once verified, the bot can decrypt messages in encrypted rooms.
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available. - `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
## Rooms (groups) ## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names): - Allowlist rooms with `channels.matrix.rooms`:
```json5 ```json5
{ {
channels: { channels: {
matrix: { matrix: {
groupPolicy: "allowlist", rooms: {
groups: { "!roomId:example.org": { requireMention: true }
"!roomId:example.org": { allow: true }, }
"#alias:example.org": { allow: true }
},
groupAllowFrom: ["@owner:example.org"]
} }
} }
} }
``` ```
- `requireMention: false` enables auto-reply in that room. - `requireMention: false` enables auto-reply in that room.
- `groups."*"` can set defaults for mention gating across rooms.
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional).
- Per-room `users` allowlists can further restrict senders inside a specific room.
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible. - The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. - On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). - To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
## Threads ## Threads
- Reply threading is supported. - Reply threading is supported.
- `channels.matrix.threadReplies` controls whether replies stay in threads: - `channels.matrix.replyToMode` controls replies when tagged:
- `off`, `inbound` (default), `always`
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
- `off` (default), `first`, `all` - `off` (default), `first`, `all`
## Capabilities ## Capabilities
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Direct messages | ✅ Supported | | Direct messages | ✅ Supported |
| Rooms | ✅ Supported | | Rooms | ✅ Supported |
| Threads | ✅ Supported | | Threads | ✅ Supported |
| Media | ✅ Supported | | Media | ✅ Supported |
| E2EE | ✅ Supported (crypto module required) | | Reactions | ✅ Supported |
| Reactions | ✅ Supported (send/read via tools) | | Polls | ✅ Supported |
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
| Location | ✅ Supported (geo URI; altitude ignored) |
| Native commands | ✅ Supported | | Native commands | ✅ Supported |
## Configuration reference (Matrix) ## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration) Full configuration: [Configuration](/gateway/configuration)
Provider options: Provider options:
- `channels.matrix.enabled`: enable/disable channel startup. - `channels.matrix.enabled`: enable/disable channel startup.
- `channels.matrix.homeserver`: homeserver URL. - `channels.matrix.homeserver`: homeserver URL.
- `channels.matrix.userId`: Matrix user ID (optional with access token). - `channels.matrix.userId`: Matrix user ID.
- `channels.matrix.accessToken`: access token. - `channels.matrix.accessToken`: access token.
- `channels.matrix.password`: password for login (token stored). - `channels.matrix.password`: password for login (token stored).
- `channels.matrix.deviceName`: device display name. - `channels.matrix.deviceName`: device display name.
- `channels.matrix.encryption`: enable E2EE (default: false).
- `channels.matrix.initialSyncLimit`: initial sync limit. - `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages.
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. - `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.groups`: group allowlist + per-room settings map. - `channels.matrix.rooms`: per-room settings and allowlist.
- `channels.matrix.rooms`: legacy group allowlist/config.
- `channels.matrix.replyToMode`: reply-to mode for threads/tags. - `channels.matrix.replyToMode`: reply-to mode for threads/tags.
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). - `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
-119
View File
@@ -1,119 +0,0 @@
---
summary: "Nextcloud Talk support status, capabilities, and configuration"
read_when:
- Working on Nextcloud Talk channel features
---
# Nextcloud Talk (plugin)
Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported.
## Plugin required
Nextcloud Talk ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/nextcloud-talk
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/nextcloud-talk
```
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Install the Nextcloud Talk plugin.
2) On your Nextcloud server, create a bot:
```bash
./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction
```
3) Enable the bot in the target room settings.
4) Configure Clawdbot:
- Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret`
- Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only)
5) Restart the gateway (or finish onboarding).
Minimal config:
```json5
{
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "shared-secret",
dmPolicy: "pairing"
}
}
}
```
## Notes
- Bots cannot initiate DMs. The user must message the bot first.
- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy.
- Media uploads are not supported by the bot API; media is sent as URLs.
- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms).
## Access control (DMs)
- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `clawdbot pairing list nextcloud-talk`
- `clawdbot pairing approve nextcloud-talk <CODE>`
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
## Rooms (groups)
- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated).
- Allowlist rooms with `channels.nextcloud-talk.rooms`:
```json5
{
channels: {
"nextcloud-talk": {
rooms: {
"room-token": { requireMention: true }
}
}
}
}
```
- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`.
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | Supported |
| Rooms | Supported |
| Threads | Not supported |
| Media | URL-only |
| Reactions | Supported |
| Native commands | Not supported |
## Configuration reference (Nextcloud Talk)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.nextcloud-talk.enabled`: enable/disable channel startup.
- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL.
- `channels.nextcloud-talk.botSecret`: bot shared secret.
- `channels.nextcloud-talk.botSecretFile`: secret file path.
- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection).
- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups.
- `channels.nextcloud-talk.apiPasswordFile`: API password file path.
- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788).
- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0).
- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook).
- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL.
- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`.
- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
-235
View File
@@ -1,235 +0,0 @@
---
summary: "Nostr DM channel via NIP-04 encrypted messages"
read_when:
- You want Clawdbot to receive DMs via Nostr
- You're setting up decentralized messaging
---
# Nostr
**Status:** Optional plugin (disabled by default).
Nostr is a decentralized protocol for social networking. This channel enables Clawdbot to receive and respond to encrypted direct messages (DMs) via NIP-04.
## Install (on demand)
### Onboarding (recommended)
- The onboarding wizard (`clawdbot onboard`) and `clawdbot channels add` list optional channel plugins.
- Selecting Nostr prompts you to install the plugin on demand.
Install defaults:
- **Dev channel + git checkout available:** uses the local plugin path.
- **Stable/Beta:** downloads from npm.
You can always override the choice in the prompt.
### Manual install
```bash
clawdbot plugins install @clawdbot/nostr
```
Use a local checkout (dev workflows):
```bash
clawdbot plugins install --link <path-to-clawdbot>/extensions/nostr
```
Restart the Gateway after installing or enabling plugins.
## Quick setup
1) Generate a Nostr keypair (if needed):
```bash
# Using nak
nak key generate
```
2) Add to config:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}"
}
}
}
```
3) Export the key:
```bash
export NOSTR_PRIVATE_KEY="nsec1..."
```
4) Restart the Gateway.
## Configuration reference
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `privateKey` | string | required | Private key in `nsec` or hex format |
| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) |
| `dmPolicy` | string | `pairing` | DM access policy |
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys |
| `enabled` | boolean | `true` | Enable/disable channel |
| `name` | string | - | Display name |
| `profile` | object | - | NIP-01 profile metadata |
## Profile metadata
Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config.
Example:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"profile": {
"name": "clawdbot",
"displayName": "Clawdbot",
"about": "Personal assistant DM bot",
"picture": "https://example.com/avatar.png",
"banner": "https://example.com/banner.png",
"website": "https://example.com",
"nip05": "clawdbot@example.com",
"lud16": "clawdbot@example.com"
}
}
}
}
```
Notes:
- Profile URLs must use `https://`.
- Importing from relays merges fields and preserves local overrides.
## Access control
### DM policies
- **pairing** (default): unknown senders get a pairing code.
- **allowlist**: only pubkeys in `allowFrom` can DM.
- **open**: public inbound DMs (requires `allowFrom: ["*"]`).
- **disabled**: ignore inbound DMs.
### Allowlist example
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "npub1xyz..."]
}
}
}
```
## Key formats
Accepted formats:
- **Private key:** `nsec...` or 64-char hex
- **Pubkeys (`allowFrom`):** `npub...` or hex
## Relays
Defaults: `relay.damus.io` and `nos.lol`.
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.wine"
]
}
}
}
```
Tips:
- Use 2-3 relays for redundancy.
- Avoid too many relays (latency, duplication).
- Paid relays can improve reliability.
- Local relays are fine for testing (`ws://localhost:7777`).
## Protocol support
| NIP | Status | Description |
| --- | --- | --- |
| NIP-01 | Supported | Basic event format + profile metadata |
| NIP-04 | Supported | Encrypted DMs (`kind:4`) |
| NIP-17 | Planned | Gift-wrapped DMs |
| NIP-44 | Planned | Versioned encryption |
## Testing
### Local relay
```bash
# Start strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
```
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["ws://localhost:7777"]
}
}
}
```
### Manual test
1) Note the bot pubkey (npub) from logs.
2) Open a Nostr client (Damus, Amethyst, etc.).
3) DM the bot pubkey.
4) Verify the response.
## Troubleshooting
### Not receiving messages
- Verify the private key is valid.
- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local).
- Confirm `enabled` is not `false`.
- Check Gateway logs for relay connection errors.
### Not sending responses
- Check relay accepts writes.
- Verify outbound connectivity.
- Watch for relay rate limits.
### Duplicate responses
- Expected when using multiple relays.
- Messages are deduplicated by event ID; only the first delivery triggers a response.
## Security
- Never commit private keys.
- Use environment variables for keys.
- Consider `allowlist` for production bots.
## Limitations (MVP)
- Direct messages only (no group chats).
- No media attachments.
- NIP-04 only (NIP-17 gift-wrap planned).
-5
View File
@@ -286,11 +286,6 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- CLI: `clawdbot message send --media <mp4> --gif-playback` - CLI: `clawdbot message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true` - Gateway: `send` params include `gifPlayback: true`
## Voice notes (PTT audio)
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. Clawdbot rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization ## Media limits + optimization
- Default outbound cap: 5 MB (per media item). - Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`. - Override: `agents.defaults.mediaMaxMb`.
-13
View File
@@ -14,16 +14,3 @@ Related:
Tip: run `clawdbot cron --help` for the full command surface. Tip: run `clawdbot cron --help` for the full command surface.
## Common edits
Update delivery settings without changing the message:
```bash
clawdbot cron edit <job-id> --deliver --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
```bash
clawdbot cron edit <job-id> --no-deliver
```
+1 -3
View File
@@ -116,7 +116,7 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
## Discover gateways (Bonjour) ## Discover gateways (Bonjour)
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`). `gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
- Multicast DNS-SD: `local.` - Multicast DNS-SD: `local.`
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour)) - Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
@@ -124,8 +124,6 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
Only gateways with Bonjour discovery enabled (default) advertise the beacon. Only gateways with Bonjour discovery enabled (default) advertise the beacon.
Wide-Area discovery records include (TXT): Wide-Area discovery records include (TXT):
- `role` (gateway role hint)
- `transport` (transport hint, e.g. `gateway`)
- `gatewayPort` (WebSocket port, usually `18789`) - `gatewayPort` (WebSocket port, usually `18789`)
- `sshPort` (SSH port; defaults to `22` if not present) - `sshPort` (SSH port; defaults to `22` if not present)
- `tailnetDns` (MagicDNS hostname, when available) - `tailnetDns` (MagicDNS hostname, when available)
+2 -2
View File
@@ -825,9 +825,9 @@ Common options:
- `--url`, `--token`, `--timeout`, `--json` - `--url`, `--token`, `--timeout`, `--json`
Subcommands: Subcommands:
- `nodes status [--connected] [--last-connected <duration>]` - `nodes status`
- `nodes describe --node <id|name|ip>` - `nodes describe --node <id|name|ip>`
- `nodes list [--connected] [--last-connected <duration>]` - `nodes list`
- `nodes pending` - `nodes pending`
- `nodes approve <requestId>` - `nodes approve <requestId>`
- `nodes reject <requestId>` - `nodes reject <requestId>`
+1 -8
View File
@@ -18,22 +18,15 @@ Related:
```bash ```bash
clawdbot nodes list clawdbot nodes list
clawdbot nodes list --connected
clawdbot nodes list --last-connected 24h
clawdbot nodes pending clawdbot nodes pending
clawdbot nodes approve <requestId> clawdbot nodes approve <requestId>
clawdbot nodes status clawdbot nodes status
clawdbot nodes status --connected
clawdbot nodes status --last-connected 24h
``` ```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
## Invoke / run ## Invoke / run
```bash ```bash
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json> clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
clawdbot nodes run --node <id|name|ip> <command...> clawdbot nodes run --node <id|name|ip> <command...>
``` ```
-5
View File
@@ -11,7 +11,6 @@ Manage Gateway plugins/extensions (loaded in-process).
Related: Related:
- Plugin system: [Plugins](/plugin) - Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security) - Security hardening: [Security](/gateway/security)
## Commands ## Commands
@@ -29,10 +28,6 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them. activate them.
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
### Install ### Install
```bash ```bash
-3
View File
@@ -114,9 +114,6 @@ clawdbot sandbox recreate --agent alfred
**Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed. **Solution:** Use `clawdbot sandbox recreate` to force removal of old containers. They'll be recreated automatically with current settings when next needed.
Tip: prefer `clawdbot sandbox recreate` over manual `docker rm`. It uses the
Gateway’s container naming and avoids mismatches when scope/session keys change.
## Configuration ## Configuration
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`): Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
-1
View File
@@ -21,4 +21,3 @@ 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. The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
-1
View File
@@ -20,5 +20,4 @@ Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available. - Overview includes Gateway + Node service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts).
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+5 -30
View File
@@ -15,9 +15,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash ```bash
clawdbot update clawdbot update
clawdbot update status
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
clawdbot update --restart clawdbot update --restart
clawdbot update --json clawdbot update --json
@@ -27,44 +25,22 @@ clawdbot --update
## Options ## Options
- `--restart`: restart the Gateway daemon after a successful update. - `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config). - `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only. - `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON. - `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s). - `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration. Note: downgrades require confirmation because older versions can break configuration.
## `update status`
Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability.
```bash
clawdbot update status
clawdbot update status --json
clawdbot update status --timeout 10
```
Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout) ## What it does (git checkout)
Channels:
- `stable`: checkout the latest non-beta tag, then build + doctor.
- `beta`: checkout the latest `-beta` tag, then build + doctor.
- `dev`: checkout `main`, then fetch + rebase.
High-level: High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch). 2. Fetches and rebases against `@{upstream}`.
3. Fetches and rebases against `@{upstream}` (dev only). 3. Installs deps (pnpm preferred; npm fallback).
4. Installs deps (pnpm preferred; npm fallback). 4. Builds + builds the Control UI.
5. Builds + builds the Control UI. 5. Runs `clawdbot doctor` as the final “safe update” check.
6. Runs `clawdbot doctor` as the final “safe update” check.
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
## `--update` shorthand ## `--update` shorthand
@@ -73,6 +49,5 @@ High-level:
## See also ## See also
- `clawdbot doctor` (offers to run update first on git checkouts) - `clawdbot doctor` (offers to run update first on git checkouts)
- [Development channels](/install/development-channels)
- [Updating](/install/updating) - [Updating](/install/updating)
- [CLI reference](/cli) - [CLI reference](/cli)
-15
View File
@@ -77,21 +77,6 @@ Client Gateway
safely retry; the server keeps a short‑lived dedupe cache. safely retry; the server keeps a short‑lived dedupe cache.
- Nodes must include `role: "node"` plus caps/commands/permissions in `connect`. - Nodes must include `role: "node"` plus caps/commands/permissions in `connect`.
## Pairing + local trust
- All WS clients (operators + nodes) include a **device identity** on `connect`.
- New device IDs require pairing approval; the Gateway issues a **device token**
for subsequent connects.
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
auto‑approved to keep same‑host UX smooth.
- **Non‑local** connects must sign the `connect.challenge` nonce and require
explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.
Details: [Gateway protocol](/gateway/protocol), [Pairing](/start/pairing),
[Security](/gateway/security).
## Protocol typing and codegen ## Protocol typing and codegen
- TypeBox schemas define the protocol. - TypeBox schemas define the protocol.
-9
View File
@@ -149,14 +149,6 @@ Control how group/room messages are handled per channel:
slack: { slack: {
groupPolicy: "allowlist", groupPolicy: "allowlist",
channels: { "#general": { allow: true } } channels: { "#general": { allow: true } }
},
matrix: {
groupPolicy: "allowlist",
groupAllowFrom: ["@owner:example.org"],
groups: {
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true }
}
} }
} }
} }
@@ -173,7 +165,6 @@ Notes:
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`. - Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
- Slack: allowlist uses `channels.slack.channels`. - Slack: allowlist uses `channels.slack.channels`.
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`).
- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive.
- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked.
-46
View File
@@ -256,52 +256,6 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
Peer bindings always win, so keep them above the channel-wide rule. Peer bindings always win, so keep them above the channel-wide rule.
## Family agent bound to a WhatsApp group
Bind a dedicated family agent to a single WhatsApp group, with mention gating
and a tighter tool policy:
```json5
{
agents: {
list: [
{
id: "family",
name: "Family",
workspace: "~/clawd-family",
identity: { name: "Family Bot" },
groupChat: {
mentionPatterns: ["@family", "@familybot", "@Family Bot"]
},
sandbox: {
mode: "all",
scope: "agent"
},
tools: {
allow: ["exec", "read", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"],
deny: ["write", "edit", "apply_patch", "browser", "canvas", "nodes", "cron"]
}
}
]
},
bindings: [
{
agentId: "family",
match: {
channel: "whatsapp",
peer: { kind: "group", id: "120363999999999999@g.us" }
}
}
]
}
```
Notes:
- Tool allow/deny lists are **tools**, not skills. If a skill needs to run a
binary, ensure `exec` is allowed and the binary exists in the sandbox.
- For stricter gating, set `agents.list[].groupChat.mentionPatterns` and keep
group allowlists enabled for the channel.
## Per-Agent Sandbox and Tool Configuration ## Per-Agent Sandbox and Tool Configuration
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions: Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
+1 -1
View File
@@ -60,7 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session. - Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility. - Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector). - Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. 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). - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
Loaded 100 of 682 files, more files were not shown because too many files have changed in this diff. Show more