Compare commits
179 Commits
matrix-wit
...
fix/node-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a7d41c6b | ||
|
|
a959e490b3 | ||
|
|
de0a488985 | ||
|
|
15f16de651 | ||
|
|
fa7df1976d | ||
|
|
2cd62f94a5 | ||
|
|
a74c19feed | ||
|
|
1ad4a7194e | ||
|
|
beec504ebd | ||
|
|
fe1133e2c5 | ||
|
|
6f37f1d8ff | ||
|
|
57700f33a9 | ||
|
|
2700794228 | ||
|
|
416894c642 | ||
|
|
db88378ae3 | ||
|
|
e97b4973bb | ||
|
|
832dfb02fe | ||
|
|
15e3a2a395 | ||
|
|
8c472c210f | ||
|
|
833bbcd166 | ||
|
|
d7440baef6 | ||
|
|
58b131919f | ||
|
|
186e86660a | ||
|
|
18d47b47d2 | ||
|
|
eb1e2c7a3b | ||
|
|
6ea4cb0012 | ||
|
|
184f5a5fc3 | ||
|
|
4ad359ffcd | ||
|
|
38cc2a3288 | ||
|
|
28c49db494 | ||
|
|
026e6c4df4 | ||
|
|
841dfc693e | ||
|
|
f38278d919 | ||
|
|
9545edcb49 | ||
|
|
f3554a3ad8 | ||
|
|
b30359e9cd | ||
|
|
d3898ee8df | ||
|
|
d1c2fc4bc8 | ||
|
|
a5a3ab958f | ||
|
|
165861e78d | ||
|
|
e7c355ee85 | ||
|
|
052a58f2f7 | ||
|
|
5ff56ffb4e | ||
|
|
9a3dd626a1 | ||
|
|
aae4b2952f | ||
|
|
aec622fe63 | ||
|
|
e6287270d9 | ||
|
|
c05a7b5390 | ||
|
|
020fecef5c | ||
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
51dfd6efdb | ||
|
|
4fad74738a | ||
|
|
69f0469530 | ||
|
|
eb1ee36f59 | ||
|
|
b341512564 | ||
|
|
6734f2d71c | ||
|
|
e12abf3114 | ||
|
|
4ad9622efb | ||
|
|
2dfd3b9a81 | ||
|
|
7b6cbf5869 | ||
|
|
8686b3b951 | ||
|
|
2e7e135bc0 | ||
|
|
c287664923 | ||
|
|
18f0051d26 | ||
|
|
b012b1105e | ||
|
|
21370fc09b | ||
|
|
4999f15688 | ||
|
|
e4f9555f21 | ||
|
|
243a8b019e | ||
|
|
5c4079f66c | ||
|
|
b1f086b536 | ||
|
|
d298b8c16b | ||
|
|
40968bd5e0 | ||
|
|
80e6c070bf | ||
|
|
26fcca087b | ||
|
|
02ca148583 | ||
|
|
ae1c6f4313 | ||
|
|
9faed2226a | ||
|
|
cf04b24632 | ||
|
|
7e59c15496 | ||
|
|
9f856abfe7 | ||
|
|
e74fd9196c | ||
|
|
40e928a4c4 | ||
|
|
079af0d0b0 | ||
|
|
faa5838147 | ||
|
|
f6abe62e5f | ||
|
|
5c5745dee5 | ||
|
|
15c735de4d | ||
|
|
8bf484bdad | ||
|
|
36719690a2 | ||
|
|
f2666d2092 | ||
|
|
a28c271488 | ||
|
|
1d9d5b30ce | ||
|
|
14f56a4e18 | ||
|
|
687c41e838 | ||
|
|
ddb7b5c6a4 | ||
|
|
262e35c219 | ||
|
|
95f0befd65 | ||
|
|
83d5e30027 | ||
|
|
842be7b864 | ||
|
|
cb5d76ed3d | ||
|
|
3d5ffee07f | ||
|
|
bd8f4b052d | ||
|
|
929d50b7d1 | ||
|
|
4fda10c508 | ||
|
|
0b0d8b2406 | ||
|
|
844ff2ee8f | ||
|
|
8c666666ef | ||
|
|
2394703593 | ||
|
|
404470853a | ||
|
|
99fc0fbac1 | ||
|
|
91ed00f800 | ||
|
|
76698ed296 | ||
|
|
716546824f | ||
|
|
74f382f732 | ||
|
|
a76aea1bc0 | ||
|
|
533766207f | ||
|
|
59fa002561 | ||
|
|
48ab168df2 | ||
|
|
bef9d5bdc8 | ||
|
|
c6812c6af4 | ||
|
|
1f7cb4b853 | ||
|
|
d161f3ab0f | ||
|
|
c65b91c841 | ||
|
|
760b1e8fc6 | ||
|
|
188893f319 | ||
|
|
04ee9e7765 | ||
|
|
390ba5f42a | ||
|
|
b8593fd4fb | ||
|
|
68a467dd66 | ||
|
|
d18319a57d | ||
|
|
15e5bb3459 | ||
|
|
41f6d06967 | ||
|
|
e3a99aa2ce | ||
|
|
c1d8456860 | ||
|
|
b8b0b3f0e7 | ||
|
|
528524e4c7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
Binary file not shown.
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
Binary file not shown.
@@ -1,87 +0,0 @@
|
||||
# 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: []
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,11 +2,16 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.20-1
|
||||
## 2026.1.20
|
||||
|
||||
### 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.
|
||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
- 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.
|
||||
@@ -14,14 +19,44 @@ Docs: https://docs.clawd.bot
|
||||
- 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
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- 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 fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
||||
- 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.
|
||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||
- 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
|
||||
|
||||
@@ -58,6 +93,7 @@ Docs: https://docs.clawd.bot
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- 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
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
@@ -70,6 +106,7 @@ Docs: https://docs.clawd.bot
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
|
||||
46
README.md
46
README.md
@@ -479,27 +479,27 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "2e6f580ad7d1e839d513aa883350369bf2e4193fad872030fdaea7827f34d8ef",
|
||||
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
@@ -10,24 +10,6 @@
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -45,24 +27,6 @@
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
versionCode = 202601200
|
||||
versionName = "2026.1.20"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -51,7 +51,7 @@ class GatewayDiscovery(
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdbot-gateway._tcp."
|
||||
private val serviceType = "_clawdbot-gw._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.20</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>20260120</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -29,7 +29,7 @@
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdbot-gateway._tcp</string>
|
||||
<string>_clawdbot-gw._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||
|
||||
@@ -7,11 +7,11 @@ import Testing
|
||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||
}
|
||||
|
||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||
@@ -22,7 +22,7 @@ import Testing
|
||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.20</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>20260120</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
CFBundleShortVersionString: "2026.1.20"
|
||||
CFBundleVersion: "20260120"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -92,7 +92,7 @@ targets:
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSBonjourServices:
|
||||
- _clawdbot-gateway._tcp
|
||||
- _clawdbot-gw._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
CFBundleShortVersionString: "2026.1.20"
|
||||
CFBundleVersion: "20260120"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
|
||||
@@ -12,8 +12,7 @@ let package = Package(
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
||||
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -67,21 +66,13 @@ let package = Package(
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
name: "ClawdbotMacCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
path: "Sources/ClawdbotMacCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
|
||||
@@ -81,7 +81,7 @@ private struct EventRow: View {
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
||||
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
|
||||
let normalized = dict.mapValues { $0.value }
|
||||
guard JSONSerialization.isValidJSONObject(normalized),
|
||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
||||
data: [
|
||||
"phase": ClawdbotProtocol.AnyCodable("start"),
|
||||
"name": ClawdbotProtocol.AnyCodable("bash"),
|
||||
],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
return AgentEventsWindow()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = ClawdbotKit.AnyCodable
|
||||
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
@@ -20,3 +25,23 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,11 +432,11 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
return self.store.resolveChannelDetailLabel(id)
|
||||
self.store.resolveChannelDetailLabel(id)
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
return self.store.resolveChannelSystemImage(id)
|
||||
self.store.resolveChannelSystemImage(id)
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
|
||||
@@ -163,9 +163,9 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
let ts: Double
|
||||
let channelOrder: [String]
|
||||
let channelLabels: [String: String]
|
||||
let channelDetailLabels: [String: String]? = nil
|
||||
let channelSystemImages: [String: String]? = nil
|
||||
let channelMeta: [ChannelUiMetaEntry]? = nil
|
||||
let channelDetailLabels: [String: String]?
|
||||
let channelSystemImages: [String: String]?
|
||||
let channelMeta: [ChannelUiMetaEntry]?
|
||||
let channels: [String: AnyCodable]
|
||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||
let channelDefaultAccountId: [String: String]
|
||||
@@ -263,7 +263,7 @@ final class ChannelsStore {
|
||||
|
||||
func orderedChannelIds() -> [String] {
|
||||
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||
return meta.map { $0.id }
|
||||
return meta.map(\.id)
|
||||
}
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
self.content
|
||||
HStack(spacing: 16) {
|
||||
self.sidebar
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(self.sections) { section in
|
||||
self.sidebarRow(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
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(.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
|
||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
||||
.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 {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
@@ -85,6 +184,204 @@ extension ConfigSettings {
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||
let seq: Int
|
||||
let stream: String
|
||||
let ts: Double
|
||||
let data: [String: AnyCodable]
|
||||
let data: [String: ClawdbotProtocol.AnyCodable]
|
||||
let summary: String?
|
||||
}
|
||||
|
||||
@@ -87,15 +87,7 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
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)
|
||||
}
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -111,7 +103,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -119,6 +111,19 @@ 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 {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
@@ -156,8 +161,8 @@ final class ControlChannel {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
||||
$0[$1.key] = AnyCodable($1.value.base)
|
||||
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
|
||||
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: method,
|
||||
@@ -275,18 +280,28 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
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 {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
@@ -346,7 +361,7 @@ final class ControlChannel {
|
||||
let phase = event.data["phase"]?.value as? String ?? ""
|
||||
let name = event.data["name"]?.value as? String
|
||||
let meta = event.data["meta"]?.value as? String
|
||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
||||
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||
WorkActivityStore.shared.handleTool(
|
||||
sessionKey: sessionKey,
|
||||
phase: phase,
|
||||
@@ -357,6 +372,27 @@ final class ControlChannel {
|
||||
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 {
|
||||
|
||||
@@ -259,6 +259,20 @@ enum ExecApprovalsPromptPresenter {
|
||||
|
||||
@MainActor
|
||||
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> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
@@ -277,14 +291,93 @@ private enum ExecHostExecutor {
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !command.isEmpty else {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||
return self.errorResponse(
|
||||
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(
|
||||
for: command,
|
||||
rawCommand: request.rawCommand)
|
||||
@@ -310,122 +403,72 @@ private enum ExecHostExecutor {
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
if security == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||
reason: "security=deny"))
|
||||
private static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
private static func allowlistPattern(
|
||||
command: [String],
|
||||
resolution: ExecCommandResolution?) -> String?
|
||||
{
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
}
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if authorized { return nil }
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "PERMISSION_MISSING: screenRecording",
|
||||
reason: "permission:screenRecording")
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if requiresAsk, approvalDecision == nil {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: trimmedAgent,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
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 approvalDecision == .allowAlways, security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
private static func runCommand(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutMs: Int?) async -> ExecHostResponse
|
||||
{
|
||||
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||
await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: request.cwd,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
}.value
|
||||
@@ -436,7 +479,24 @@ private enum ExecHostExecutor {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
return ExecHostResponse(
|
||||
return self.successResponse(payload)
|
||||
}
|
||||
|
||||
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",
|
||||
id: UUID().uuidString,
|
||||
ok: true,
|
||||
|
||||
@@ -148,6 +148,27 @@ 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
|
||||
case .remote:
|
||||
let nsError = error as NSError
|
||||
@@ -244,9 +265,9 @@ actor GatewayConnection {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
||||
(defaults?[key]?.stringValue ?? "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
|
||||
let raw = defaults?[key]?.value as? String
|
||||
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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,6 +68,7 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -165,19 +166,23 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
return configToken
|
||||
}
|
||||
|
||||
if isRemote {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -469,6 +474,36 @@ 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(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
@@ -524,12 +559,17 @@ actor GatewayEndpointStore {
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet", "auto":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
case "tailnet":
|
||||
return tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||
return tailscaleIP
|
||||
}
|
||||
return "127.0.0.1"
|
||||
case "custom":
|
||||
customBindHost ?? "127.0.0.1"
|
||||
return customBindHost ?? "127.0.0.1"
|
||||
default:
|
||||
"127.0.0.1"
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,11 +640,12 @@ extension GatewayEndpointStore {
|
||||
|
||||
static func _testResolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
tailscaleIP: String?,
|
||||
customBindHost: String? = nil) -> String
|
||||
{
|
||||
self.resolveLocalGatewayHost(
|
||||
bindMode: bindMode,
|
||||
customBindHost: nil,
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ClawdbotApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
|
||||
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
|
||||
struct OnboardingWizardStepView: View {
|
||||
let step: WizardStep
|
||||
let isSubmitting: Bool
|
||||
let onSubmit: (AnyCodable?) -> Void
|
||||
let onStepSubmit: (AnyCodable?) -> Void
|
||||
|
||||
@State private var textValue: String
|
||||
@State private var confirmValue: Bool
|
||||
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
|
||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||
self.step = step
|
||||
self.isSubmitting = isSubmitting
|
||||
self.onSubmit = onSubmit
|
||||
self.onStepSubmit = onSubmit
|
||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||
WizardOptionItem(index: index, option: option)
|
||||
}
|
||||
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
case "text":
|
||||
self.onSubmit(AnyCodable(self.textValue))
|
||||
self.onStepSubmit(AnyCodable(self.textValue))
|
||||
case "confirm":
|
||||
self.onSubmit(AnyCodable(self.confirmValue))
|
||||
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||
case "select":
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
return
|
||||
}
|
||||
let option = self.optionItems[self.selectedIndex].option
|
||||
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
case "multiselect":
|
||||
let values = self.optionItems
|
||||
.filter { self.selectedIndices.contains($0.index) }
|
||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||
self.onSubmit(AnyCodable(values))
|
||||
self.onStepSubmit(AnyCodable(values))
|
||||
case "action":
|
||||
self.onSubmit(AnyCodable(true))
|
||||
self.onStepSubmit(AnyCodable(true))
|
||||
default:
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.20</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>202601200</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -2,6 +2,9 @@ import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Manages Tailscale integration and status checking.
|
||||
@Observable
|
||||
@@ -100,16 +103,14 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
guard self.isInstalled else {
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not installed"
|
||||
return
|
||||
}
|
||||
|
||||
if let apiResponse = await fetchTailscaleStatus() {
|
||||
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
if self.isRunning {
|
||||
@@ -138,6 +139,19 @@ final class TailscaleService {
|
||||
self.statusError = "Please start the Tailscale app"
|
||||
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() {
|
||||
@@ -163,4 +177,50 @@ final class TailscaleService {
|
||||
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 domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
|
||||
{
|
||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
|
||||
let ips = candidates
|
||||
candidates.removeAll(keepingCapacity: true)
|
||||
|
||||
@@ -1,150 +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]
|
||||
}
|
||||
|
||||
@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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,8 +408,7 @@ extension Request: Codable {
|
||||
}
|
||||
|
||||
// Shared transport settings
|
||||
public let controlSocketPath =
|
||||
FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
public let controlSocketPath = FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
|
||||
359
apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift
Normal file
359
apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift
Normal file
@@ -0,0 +1,359 @@
|
||||
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
|
||||
}
|
||||
149
apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift
Normal file
149
apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
56
apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift
Normal file
56
apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
""")
|
||||
}
|
||||
60
apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift
Normal file
60
apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
5
apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift
Normal file
5
apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
typealias KitAnyCodable = ClawdbotKit.AnyCodable
|
||||
@@ -3,8 +3,6 @@ import ClawdbotProtocol
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
struct WizardCliOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
@@ -51,17 +49,6 @@ 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 {
|
||||
case invalidUrl(String)
|
||||
case missingRemoteUrl
|
||||
@@ -80,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotWizardCLI {
|
||||
static func main() async {
|
||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
func runWizardCommand(_ args: [String]) async {
|
||||
let opts = WizardCliOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac wizard
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
}
|
||||
let endpoint = try resolveGatewayEndpoint(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)
|
||||
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
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
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 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 {
|
||||
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
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()
|
||||
@@ -153,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
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
|
||||
@@ -164,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
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? {
|
||||
@@ -189,47 +166,6 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
||||
return config.password
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
|
||||
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 enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
@@ -365,7 +301,8 @@ actor GatewayWizardClient {
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
@@ -410,10 +347,11 @@ actor GatewayWizardClient {
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try decodeFrame(message)
|
||||
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 {
|
||||
let nonce = payload["nonce"]?.value as? String
|
||||
{
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let replychannel: String?
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
replychannel: String?,
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.replychannel = replychannel
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case replychannel = "replyChannel"
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?
|
||||
agentid: String?,
|
||||
search: String?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.search = search
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct ChannelsSettingsSmokeTests {
|
||||
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channelDetailLabels: nil,
|
||||
channelSystemImages: nil,
|
||||
channelMeta: nil,
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
"lastEventAt": 1_700_000_060_000,
|
||||
"lastError": "needs login",
|
||||
]),
|
||||
"telegram": AnyCodable([
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"tokenSource": "env",
|
||||
"running": true,
|
||||
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"signal": AnyCodable([
|
||||
"signal": SnapshotAnyCodable([
|
||||
"configured": true,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": true,
|
||||
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"imessage": AnyCodable([
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
channelDetailLabels: nil,
|
||||
channelSystemImages: nil,
|
||||
channelMeta: nil,
|
||||
channels: [
|
||||
"whatsapp": AnyCodable([
|
||||
"whatsapp": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
]),
|
||||
"telegram": AnyCodable([
|
||||
"telegram": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "bot missing",
|
||||
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_100_000,
|
||||
]),
|
||||
"signal": AnyCodable([
|
||||
"signal": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": false,
|
||||
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
|
||||
],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
"imessage": AnyCodable([
|
||||
"imessage": SnapshotAnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
|
||||
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
|
||||
}
|
||||
|
||||
@Test func cronJobEditorBuildsBodyForNewJob() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
|
||||
job: job,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func cronJobEditorExercisesBuilders() {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
var view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
let channelsStore = ChannelsStore(isPreview: true)
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
channelsStore: channelsStore,
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
|
||||
var root: [String: Any] = [:]
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||
let raw = root["deleteAfterRun"] as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@@ -139,4 +139,40 @@ import Testing
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#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,15 +7,17 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct LowCoverageHelperTests {
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@Test func anyCodableHelperAccessors() throws {
|
||||
let payload: [String: AnyCodable] = [
|
||||
"title": AnyCodable("Hello"),
|
||||
"flag": AnyCodable(true),
|
||||
"count": AnyCodable(3),
|
||||
"ratio": AnyCodable(1.25),
|
||||
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
|
||||
let payload: [String: ProtoAnyCodable] = [
|
||||
"title": ProtoAnyCodable("Hello"),
|
||||
"flag": ProtoAnyCodable(true),
|
||||
"count": ProtoAnyCodable(3),
|
||||
"ratio": ProtoAnyCodable(1.25),
|
||||
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
|
||||
]
|
||||
let any = AnyCodable(payload)
|
||||
let any = ProtoAnyCodable(payload)
|
||||
let dict = try #require(any.dictionaryValue)
|
||||
#expect(dict["title"]?.stringValue == "Hello")
|
||||
#expect(dict["flag"]?.boolValue == true)
|
||||
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
|
||||
#expect(result.stderr.contains("stderr-1999"))
|
||||
}
|
||||
|
||||
@Test func pairedNodesStorePersists() async throws {
|
||||
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(
|
||||
@Test func nodeInfoCodableRoundTrip() throws {
|
||||
let info = NodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0",
|
||||
coreVersion: "1.0-core",
|
||||
uiVersion: "1.0-ui",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro",
|
||||
token: "token",
|
||||
createdAtMs: 1,
|
||||
lastSeenAtMs: nil)
|
||||
try await store.upsert(node)
|
||||
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
|
||||
|
||||
try await store.touchSeen(nodeId: "node-1")
|
||||
let updated = await store.find(nodeId: "node-1")
|
||||
#expect(updated?.lastSeenAtMs != nil)
|
||||
remoteIp: "192.168.1.2",
|
||||
caps: ["chat"],
|
||||
commands: ["send"],
|
||||
permissions: ["send": true],
|
||||
paired: true,
|
||||
connected: false)
|
||||
let data = try JSONEncoder().encode(info)
|
||||
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
|
||||
#expect(decoded.nodeId == "node-1")
|
||||
#expect(decoded.isPaired == true)
|
||||
#expect(decoded.isConnected == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func presenceReporterHelpers() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import Testing
|
||||
features: [:],
|
||||
snapshot: snapshot,
|
||||
canvashosturl: nil,
|
||||
auth: nil,
|
||||
policy: [:])
|
||||
|
||||
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
|
||||
|
||||
@@ -3,13 +3,15 @@ import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingWizardStepViewTests {
|
||||
@Test func noteStepBuilds() {
|
||||
let step = WizardStep(
|
||||
id: "step-1",
|
||||
type: AnyCodable("note"),
|
||||
type: ProtoAnyCodable("note"),
|
||||
title: "Welcome",
|
||||
message: "Hello",
|
||||
options: nil,
|
||||
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
|
||||
}
|
||||
|
||||
@Test func selectStepBuilds() {
|
||||
let options: [[String: AnyCodable]] = [
|
||||
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
|
||||
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
|
||||
let options: [[String: ProtoAnyCodable]] = [
|
||||
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
|
||||
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
|
||||
]
|
||||
let step = WizardStep(
|
||||
id: "step-2",
|
||||
type: AnyCodable("select"),
|
||||
type: ProtoAnyCodable("select"),
|
||||
title: "Mode",
|
||||
message: "Choose a mode",
|
||||
options: options,
|
||||
initialvalue: AnyCodable("local"),
|
||||
initialvalue: ProtoAnyCodable("local"),
|
||||
placeholder: nil,
|
||||
sensitive: nil,
|
||||
executor: nil)
|
||||
|
||||
@@ -20,7 +20,7 @@ struct WideAreaGatewayDiscoveryTests {
|
||||
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||
if recordType == "PTR" {
|
||||
if nameserver == "@100.123.224.76" {
|
||||
return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
|
||||
return "steipetacstudio-gateway._clawdbot-gw._tcp.clawdbot.internal.\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
public enum ClawdbotBonjour {
|
||||
// v0: internal-only, subject to rename.
|
||||
public static let gatewayServiceType = "_clawdbot-gateway._tcp"
|
||||
public static let gatewayServiceType = "_clawdbot-gw._tcp"
|
||||
public static let gatewayServiceDomain = "local."
|
||||
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
|
||||
|
||||
public struct WebSocketTaskBox: @unchecked Sendable {
|
||||
public let task: any WebSocketTasking
|
||||
public init(task: any WebSocketTasking) {
|
||||
self.task = task
|
||||
}
|
||||
|
||||
public var state: URLSessionTask.State { self.task.state }
|
||||
|
||||
|
||||
@@ -29,5 +29,10 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
public let method: 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)" }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,35 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
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 {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
@@ -23,6 +52,45 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
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 canvasHostUrl: String?
|
||||
|
||||
@@ -167,7 +235,11 @@ public actor GatewayNodeSession {
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await onInvoke(req)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -180,8 +252,10 @@ public actor GatewayNodeSession {
|
||||
"id": AnyCodable(request.id),
|
||||
"nodeId": AnyCodable(request.nodeId),
|
||||
"ok": AnyCodable(response.ok),
|
||||
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
|
||||
]
|
||||
if let payloadJSON = response.payloadJSON {
|
||||
params["payloadJSON"] = AnyCodable(payloadJSON)
|
||||
}
|
||||
if let error = response.error {
|
||||
params["error"] = AnyCodable([
|
||||
"code": AnyCodable(error.code.rawValue),
|
||||
|
||||
@@ -36,7 +36,7 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
private let params: GatewayTLSParams
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
@@ -96,10 +96,12 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
}
|
||||
|
||||
private func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||
let count = SecTrustGetCertificateCount(trust)
|
||||
guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
return sha256Hex(data)
|
||||
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return sha256Hex(SecCertificateCopyData(cert) as Data)
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
|
||||
@@ -473,6 +473,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let replychannel: String?
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -494,6 +495,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
replychannel: String?,
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -514,6 +516,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.replychannel = replychannel
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -535,6 +538,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case replychannel = "replyChannel"
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -835,35 +839,47 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?
|
||||
agentid: String?,
|
||||
search: String?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.search = search
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,6 +1340,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let channelorder: [String]
|
||||
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 channelaccounts: [String: AnyCodable]
|
||||
public let channeldefaultaccountid: [String: AnyCodable]
|
||||
@@ -1332,6 +1351,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
ts: Int,
|
||||
channelorder: [String],
|
||||
channellabels: [String: AnyCodable],
|
||||
channeldetaillabels: [String: AnyCodable]?,
|
||||
channelsystemimages: [String: AnyCodable]?,
|
||||
channelmeta: [[String: AnyCodable]]?,
|
||||
channels: [String: AnyCodable],
|
||||
channelaccounts: [String: AnyCodable],
|
||||
channeldefaultaccountid: [String: AnyCodable]
|
||||
@@ -1339,6 +1361,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
self.ts = ts
|
||||
self.channelorder = channelorder
|
||||
self.channellabels = channellabels
|
||||
self.channeldetaillabels = channeldetaillabels
|
||||
self.channelsystemimages = channelsystemimages
|
||||
self.channelmeta = channelmeta
|
||||
self.channels = channels
|
||||
self.channelaccounts = channelaccounts
|
||||
self.channeldefaultaccountid = channeldefaultaccountid
|
||||
@@ -1347,6 +1372,9 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
case ts
|
||||
case channelorder = "channelOrder"
|
||||
case channellabels = "channelLabels"
|
||||
case channeldetaillabels = "channelDetailLabels"
|
||||
case channelsystemimages = "channelSystemImages"
|
||||
case channelmeta = "channelMeta"
|
||||
case channels
|
||||
case channelaccounts = "channelAccounts"
|
||||
case channeldefaultaccountid = "channelDefaultAccountId"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,11 @@ 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”
|
||||
(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:
|
||||
- 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).
|
||||
|
||||
@@ -21,6 +21,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [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).
|
||||
- [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 Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
235
docs/channels/nostr.md
Normal file
235
docs/channels/nostr.md
Normal file
@@ -0,0 +1,235 @@
|
||||
---
|
||||
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).
|
||||
@@ -286,6 +286,11 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
- CLI: `clawdbot message send --media <mp4> --gif-playback`
|
||||
- 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
|
||||
- Default outbound cap: 5 MB (per media item).
|
||||
- Override: `agents.defaults.mediaMaxMb`.
|
||||
|
||||
@@ -14,3 +14,16 @@ Related:
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -116,7 +116,7 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
||||
|
||||
## Discover gateways (Bonjour)
|
||||
|
||||
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
|
||||
`gateway discover` scans for Gateway beacons (`_clawdbot-gw._tcp`).
|
||||
|
||||
- Multicast DNS-SD: `local.`
|
||||
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
||||
|
||||
@@ -825,9 +825,9 @@ Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
Subcommands:
|
||||
- `nodes status`
|
||||
- `nodes status [--connected] [--last-connected <duration>]`
|
||||
- `nodes describe --node <id|name|ip>`
|
||||
- `nodes list`
|
||||
- `nodes list [--connected] [--last-connected <duration>]`
|
||||
- `nodes pending`
|
||||
- `nodes approve <requestId>`
|
||||
- `nodes reject <requestId>`
|
||||
|
||||
@@ -18,15 +18,22 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot nodes list
|
||||
clawdbot nodes list --connected
|
||||
clawdbot nodes list --last-connected 24h
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
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
|
||||
|
||||
```bash
|
||||
clawdbot nodes invoke --node <id|name|ip> --command <command> --params <json>
|
||||
clawdbot nodes run --node <id|name|ip> <command...>
|
||||
```
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
|
||||
|
||||
@@ -21,3 +21,4 @@ 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.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
|
||||
@@ -64,6 +64,7 @@ High-level:
|
||||
4. Installs deps (pnpm preferred; npm fallback).
|
||||
5. Builds + builds the Control UI.
|
||||
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
|
||||
|
||||
|
||||
@@ -77,6 +77,21 @@ Client Gateway
|
||||
safely retry; the server keeps a short‑lived dedupe cache.
|
||||
- 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
|
||||
|
||||
- TypeBox schemas define the protocol.
|
||||
|
||||
@@ -256,6 +256,52 @@ Keep WhatsApp on the fast agent, but route one DM to Opus:
|
||||
|
||||
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
|
||||
|
||||
Starting with v2026.1.6, each agent can have its own sandbox and tool restrictions:
|
||||
|
||||
@@ -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.
|
||||
- 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).
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/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.
|
||||
- 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).
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ boundary. You can keep the same discovery UX by switching to **unicast DNS‑SD*
|
||||
High‑level steps:
|
||||
|
||||
1) Run a DNS server on the gateway host (reachable over Tailnet).
|
||||
2) Publish DNS‑SD records for `_clawdbot-bridge._tcp` under a dedicated zone
|
||||
2) Publish DNS‑SD records for `_clawdbot-gw._tcp` under a dedicated zone
|
||||
(example: `clawdbot.internal.`).
|
||||
3) Configure Tailscale **split DNS** so `clawdbot.internal` resolves via that
|
||||
DNS server for clients (including iOS).
|
||||
@@ -49,8 +49,8 @@ This installs CoreDNS and configures it to:
|
||||
Validate from a tailnet‑connected machine:
|
||||
|
||||
```bash
|
||||
dns-sd -B _clawdbot-bridge._tcp clawdbot.internal.
|
||||
dig @<TAILNET_IPV4> -p 53 _clawdbot-bridge._tcp.clawdbot.internal PTR +short
|
||||
dns-sd -B _clawdbot-gw._tcp clawdbot.internal.
|
||||
dig @<TAILNET_IPV4> -p 53 _clawdbot-gw._tcp.clawdbot.internal PTR +short
|
||||
```
|
||||
|
||||
### Tailscale DNS settings
|
||||
@@ -61,7 +61,7 @@ In the Tailscale admin console:
|
||||
- Add split DNS so the domain `clawdbot.internal` uses that nameserver.
|
||||
|
||||
Once clients accept tailnet DNS, iOS nodes can browse
|
||||
`_clawdbot-bridge._tcp` in `clawdbot.internal.` without multicast.
|
||||
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast.
|
||||
|
||||
### Bridge listener security (recommended)
|
||||
|
||||
@@ -74,11 +74,11 @@ For tailnet‑only setups:
|
||||
|
||||
## What advertises
|
||||
|
||||
Only the Gateway (when the **bridge is enabled**) advertises `_clawdbot-bridge._tcp`.
|
||||
Only the Gateway advertises `_clawdbot-gw._tcp`.
|
||||
|
||||
## Service types
|
||||
|
||||
- `_clawdbot-bridge._tcp` — bridge transport beacon (used by macOS/iOS/Android nodes).
|
||||
- `_clawdbot-gw._tcp` — gateway transport beacon (used by macOS/iOS/Android nodes).
|
||||
|
||||
## TXT keys (non‑secret hints)
|
||||
|
||||
@@ -101,11 +101,11 @@ Useful built‑in tools:
|
||||
|
||||
- Browse instances:
|
||||
```bash
|
||||
dns-sd -B _clawdbot-bridge._tcp local.
|
||||
dns-sd -B _clawdbot-gw._tcp local.
|
||||
```
|
||||
- Resolve one instance (replace `<instance>`):
|
||||
```bash
|
||||
dns-sd -L "<instance>" _clawdbot-bridge._tcp local.
|
||||
dns-sd -L "<instance>" _clawdbot-gw._tcp local.
|
||||
```
|
||||
|
||||
If browsing works but resolving fails, you’re usually hitting a LAN policy or
|
||||
@@ -122,7 +122,7 @@ The Gateway writes a rolling log file (printed on startup as
|
||||
|
||||
## Debugging on iOS node
|
||||
|
||||
The iOS node uses `NWBrowser` to discover `_clawdbot-bridge._tcp`.
|
||||
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`.
|
||||
|
||||
To capture logs:
|
||||
- Settings → Bridge → Advanced → **Discovery Debug Logs**
|
||||
|
||||
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
|
||||
- `alias` (optional model shortcut, e.g. `/opus`).
|
||||
- `params` (optional provider-specific API params passed through to the model request).
|
||||
|
||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change.
|
||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Anthropic API defaults to `"1h"` unless you override (`cacheControlTtl: "5m"`). Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -1774,6 +1774,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.maxRedirects` (default 3)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||
@@ -2614,10 +2615,13 @@ Defaults:
|
||||
// noSandbox: false,
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||
|
||||
### `ui` (Appearance)
|
||||
|
||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||
@@ -2988,7 +2992,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
|
||||
|
||||
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
||||
|
||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||
|
||||
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
||||
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
||||
|
||||
@@ -51,7 +51,7 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
#### Service beacon details
|
||||
|
||||
- Service types:
|
||||
- `_clawdbot-bridge._tcp` (bridge transport beacon)
|
||||
- `_clawdbot-gw._tcp` (gateway transport beacon)
|
||||
- TXT keys (non-secret):
|
||||
- `role=gateway`
|
||||
- `lanHost=<hostname>.local`
|
||||
|
||||
@@ -195,6 +195,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- Gateways issue tokens per device + role.
|
||||
- Pairing approvals are required for new device IDs unless local auto-approval
|
||||
is enabled.
|
||||
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||
(so same‑host tailnet binds can still auto‑approve).
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ Build it once:
|
||||
scripts/sandbox-setup.sh
|
||||
```
|
||||
|
||||
Note: the default image does **not** include Node. If a skill needs Node (or
|
||||
other runtimes), either bake a custom image or install via
|
||||
`sandbox.docker.setupCommand` (requires network egress + writable root +
|
||||
root user).
|
||||
|
||||
Sandboxed browser image:
|
||||
```bash
|
||||
scripts/sandbox-browser-setup.sh
|
||||
@@ -129,6 +134,8 @@ Common pitfalls:
|
||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
||||
- Sandbox exec does **not** inherit host `process.env`. Use
|
||||
`agents.defaults.sandbox.docker.env` (or a custom image) for skill API keys.
|
||||
|
||||
## Tool policy + escape hatches
|
||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||
|
||||
@@ -177,6 +177,7 @@ Recommendations:
|
||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||
|
||||
## Reasoning & verbose output in groups
|
||||
|
||||
@@ -269,6 +270,12 @@ Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||
|
||||
Local device pairing:
|
||||
- Device pairing is auto‑approved for **local** connects (loopback or the
|
||||
gateway host’s own tailnet address) to keep same‑host clients smooth.
|
||||
- Other tailnet peers are **not** treated as local; they still need pairing
|
||||
approval.
|
||||
|
||||
Auth modes:
|
||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||
|
||||
@@ -87,6 +87,17 @@ WhatsApp + Telegram channels require **Node**; Bun is unsupported. If your
|
||||
service was installed with Bun or a version-managed Node path, run `clawdbot doctor`
|
||||
to migrate to a system Node install.
|
||||
|
||||
### Skill missing API key in sandbox
|
||||
|
||||
**Symptom:** Skill works on host but fails in sandbox with missing API key.
|
||||
|
||||
**Why:** sandboxed exec runs inside Docker and does **not** inherit host `process.env`.
|
||||
|
||||
**Fix:**
|
||||
- set `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
|
||||
- or bake the key into your custom sandbox image
|
||||
- then run `clawdbot sandbox recreate --agent <id>` (or `--all`)
|
||||
|
||||
### Service Running but Port Not Listening
|
||||
|
||||
If the service reports **running** but nothing is listening on the gateway port,
|
||||
|
||||
@@ -7,6 +7,8 @@ read_when:
|
||||
|
||||
# Development channels
|
||||
|
||||
Last updated: 2026-01-20
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||
@@ -38,6 +40,13 @@ This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||
|
||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||
|
||||
## Plugins and channels
|
||||
|
||||
When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sources:
|
||||
|
||||
- `dev` prefers bundled plugins from the git checkout.
|
||||
- `stable` and `beta` restore npm-installed plugin packages.
|
||||
|
||||
## Tagging best practices
|
||||
|
||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
|
||||
173
docs/logging.md
173
docs/logging.md
@@ -136,6 +136,179 @@ Tool summaries can redact sensitive tokens before they hit the console:
|
||||
|
||||
Redaction affects **console output only** and does not alter file logs.
|
||||
|
||||
## Diagnostics + OpenTelemetry
|
||||
|
||||
Diagnostics are structured, machine-readable events for model runs **and**
|
||||
message-flow telemetry (webhooks, queueing, session state). They do **not**
|
||||
replace logs; they exist to feed metrics, traces, and other exporters.
|
||||
|
||||
Diagnostics events are emitted in-process, but exporters only attach when
|
||||
diagnostics + the exporter plugin are enabled.
|
||||
|
||||
### OpenTelemetry vs OTLP
|
||||
|
||||
- **OpenTelemetry (OTel)**: the data model + SDKs for traces, metrics, and logs.
|
||||
- **OTLP**: the wire protocol used to export OTel data to a collector/backend.
|
||||
- Clawdbot exports via **OTLP/HTTP (protobuf)** today.
|
||||
|
||||
### Signals exported
|
||||
|
||||
- **Metrics**: counters + histograms (token usage, message flow, queueing).
|
||||
- **Traces**: spans for model usage + webhook/message processing.
|
||||
- **Logs**: exported over OTLP when `diagnostics.otel.logs` is enabled. Log
|
||||
volume can be high; keep `logging.level` and exporter filters in mind.
|
||||
|
||||
### Diagnostic event catalog
|
||||
|
||||
Model usage:
|
||||
- `model.usage`: tokens, cost, duration, context, provider/model/channel, session ids.
|
||||
|
||||
Message flow:
|
||||
- `webhook.received`: webhook ingress per channel.
|
||||
- `webhook.processed`: webhook handled + duration.
|
||||
- `webhook.error`: webhook handler errors.
|
||||
- `message.queued`: message enqueued for processing.
|
||||
- `message.processed`: outcome + duration + optional error.
|
||||
|
||||
Queue + session:
|
||||
- `queue.lane.enqueue`: command queue lane enqueue + depth.
|
||||
- `queue.lane.dequeue`: command queue lane dequeue + wait time.
|
||||
- `session.state`: session state transition + reason.
|
||||
- `session.stuck`: session stuck warning + age.
|
||||
- `run.attempt`: run retry/attempt metadata.
|
||||
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
|
||||
|
||||
### Enable diagnostics (no exporter)
|
||||
|
||||
Use this if you want diagnostics events available to plugins or custom sinks:
|
||||
|
||||
```json
|
||||
{
|
||||
"diagnostics": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Export to OpenTelemetry
|
||||
|
||||
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
||||
works with any OpenTelemetry collector/backend that accepts OTLP/HTTP.
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"allow": ["diagnostics-otel"],
|
||||
"entries": {
|
||||
"diagnostics-otel": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": {
|
||||
"enabled": true,
|
||||
"otel": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://otel-collector:4318",
|
||||
"protocol": "http/protobuf",
|
||||
"serviceName": "clawdbot-gateway",
|
||||
"traces": true,
|
||||
"metrics": true,
|
||||
"logs": true,
|
||||
"sampleRate": 0.2,
|
||||
"flushIntervalMs": 60000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- You can also enable the plugin with `clawdbot plugins enable diagnostics-otel`.
|
||||
- `protocol` currently supports `http/protobuf` only. `grpc` is ignored.
|
||||
- Metrics include token usage, cost, context size, run duration, and message-flow
|
||||
counters/histograms (webhooks, queueing, session state, queue depth/wait).
|
||||
- Traces/metrics can be toggled with `traces` / `metrics` (default: on). Traces
|
||||
include model usage spans plus webhook/message processing spans when enabled.
|
||||
- Set `headers` when your collector requires auth.
|
||||
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
|
||||
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
|
||||
### Exported metrics (names + types)
|
||||
|
||||
Model usage:
|
||||
- `clawdbot.tokens` (counter, attrs: `clawdbot.token`, `clawdbot.channel`,
|
||||
`clawdbot.provider`, `clawdbot.model`)
|
||||
- `clawdbot.cost.usd` (counter, attrs: `clawdbot.channel`, `clawdbot.provider`,
|
||||
`clawdbot.model`)
|
||||
- `clawdbot.run.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||
`clawdbot.provider`, `clawdbot.model`)
|
||||
- `clawdbot.context.tokens` (histogram, attrs: `clawdbot.context`,
|
||||
`clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`)
|
||||
|
||||
Message flow:
|
||||
- `clawdbot.webhook.received` (counter, attrs: `clawdbot.channel`,
|
||||
`clawdbot.webhook`)
|
||||
- `clawdbot.webhook.error` (counter, attrs: `clawdbot.channel`,
|
||||
`clawdbot.webhook`)
|
||||
- `clawdbot.webhook.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||
`clawdbot.webhook`)
|
||||
- `clawdbot.message.queued` (counter, attrs: `clawdbot.channel`,
|
||||
`clawdbot.source`)
|
||||
- `clawdbot.message.processed` (counter, attrs: `clawdbot.channel`,
|
||||
`clawdbot.outcome`)
|
||||
- `clawdbot.message.duration_ms` (histogram, attrs: `clawdbot.channel`,
|
||||
`clawdbot.outcome`)
|
||||
|
||||
Queues + sessions:
|
||||
- `clawdbot.queue.lane.enqueue` (counter, attrs: `clawdbot.lane`)
|
||||
- `clawdbot.queue.lane.dequeue` (counter, attrs: `clawdbot.lane`)
|
||||
- `clawdbot.queue.depth` (histogram, attrs: `clawdbot.lane` or
|
||||
`clawdbot.channel=heartbeat`)
|
||||
- `clawdbot.queue.wait_ms` (histogram, attrs: `clawdbot.lane`)
|
||||
- `clawdbot.session.state` (counter, attrs: `clawdbot.state`, `clawdbot.reason`)
|
||||
- `clawdbot.session.stuck` (counter, attrs: `clawdbot.state`)
|
||||
- `clawdbot.session.stuck_age_ms` (histogram, attrs: `clawdbot.state`)
|
||||
- `clawdbot.run.attempt` (counter, attrs: `clawdbot.attempt`)
|
||||
|
||||
### Exported spans (names + key attributes)
|
||||
|
||||
- `clawdbot.model.usage`
|
||||
- `clawdbot.channel`, `clawdbot.provider`, `clawdbot.model`
|
||||
- `clawdbot.sessionKey`, `clawdbot.sessionId`
|
||||
- `clawdbot.tokens.*` (input/output/cache_read/cache_write/total)
|
||||
- `clawdbot.webhook.processed`
|
||||
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`
|
||||
- `clawdbot.webhook.error`
|
||||
- `clawdbot.channel`, `clawdbot.webhook`, `clawdbot.chatId`,
|
||||
`clawdbot.error`
|
||||
- `clawdbot.message.processed`
|
||||
- `clawdbot.channel`, `clawdbot.outcome`, `clawdbot.chatId`,
|
||||
`clawdbot.messageId`, `clawdbot.sessionKey`, `clawdbot.sessionId`,
|
||||
`clawdbot.reason`
|
||||
- `clawdbot.session.stuck`
|
||||
- `clawdbot.state`, `clawdbot.ageMs`, `clawdbot.queueDepth`,
|
||||
`clawdbot.sessionKey`, `clawdbot.sessionId`
|
||||
|
||||
### Sampling + flushing
|
||||
|
||||
- Trace sampling: `diagnostics.otel.sampleRate` (0.0–1.0, root spans only).
|
||||
- Metric export interval: `diagnostics.otel.flushIntervalMs` (min 1000ms).
|
||||
|
||||
### Protocol notes
|
||||
|
||||
- OTLP/HTTP endpoints can be set via `diagnostics.otel.endpoint` or
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
- If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is.
|
||||
- If the endpoint already contains `/v1/logs`, it is used as-is for logs.
|
||||
- `diagnostics.otel.logs` enables OTLP log export for the main logger output.
|
||||
|
||||
### Log export behavior
|
||||
|
||||
- OTLP logs use the same structured records written to `logging.file`.
|
||||
- Respect `logging.level` (file log level). Console redaction does **not** apply
|
||||
to OTLP logs.
|
||||
- High-volume installs should prefer OTLP collector sampling/filtering.
|
||||
|
||||
## Troubleshooting tips
|
||||
|
||||
- **Gateway not reachable?** Run `clawdbot doctor` first.
|
||||
|
||||
51
docs/network.md
Normal file
51
docs/network.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
summary: "Network hub: gateway surfaces, pairing, discovery, and security"
|
||||
read_when:
|
||||
- You need the network architecture + security overview
|
||||
- You are debugging local vs tailnet access or pairing
|
||||
- You want the canonical list of networking docs
|
||||
---
|
||||
# Network hub
|
||||
|
||||
This hub links the core docs for how Clawdbot connects, pairs, and secures
|
||||
devices across localhost, LAN, and tailnet.
|
||||
|
||||
## Core model
|
||||
|
||||
- [Gateway architecture](/concepts/architecture)
|
||||
- [Gateway protocol](/gateway/protocol)
|
||||
- [Gateway runbook](/gateway)
|
||||
- [Web surfaces + bind modes](/web)
|
||||
|
||||
## Pairing + identity
|
||||
|
||||
- [Pairing overview (DM + nodes)](/start/pairing)
|
||||
- [Gateway-owned node pairing](/gateway/pairing)
|
||||
- [Devices CLI (pairing + token rotation)](/cli/devices)
|
||||
- [Pairing CLI (DM approvals)](/cli/pairing)
|
||||
|
||||
Local trust:
|
||||
- Local connections (loopback or the gateway host’s own tailnet address) can be
|
||||
auto‑approved for pairing to keep same‑host UX smooth.
|
||||
- Non‑local tailnet/LAN clients still require explicit pairing approval.
|
||||
|
||||
## Discovery + transports
|
||||
|
||||
- [Discovery & transports](/gateway/discovery)
|
||||
- [Bonjour / mDNS](/gateway/bonjour)
|
||||
- [Remote access (SSH)](/gateway/remote)
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
|
||||
## Nodes + bridge
|
||||
|
||||
- [Nodes overview](/nodes)
|
||||
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||
- [Node runbook: iOS](/platforms/ios)
|
||||
- [Node runbook: Android](/platforms/android)
|
||||
|
||||
## Security
|
||||
|
||||
- [Security overview](/gateway/security)
|
||||
- [Gateway config reference](/gateway/configuration)
|
||||
- [Troubleshooting](/gateway/troubleshooting)
|
||||
- [Doctor](/gateway/doctor)
|
||||
@@ -34,6 +34,81 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
|
||||
Notes:
|
||||
- `nodes rename` stores a display name override in the gateway pairing store.
|
||||
|
||||
## Remote node host (system.run)
|
||||
|
||||
Use a **node host** when your Gateway runs on one machine and you want commands
|
||||
to execute on another. The model still talks to the **gateway**; the gateway
|
||||
forwards `exec` calls to the **node host** when `host=node` is selected.
|
||||
|
||||
### What runs where
|
||||
- **Gateway host**: receives messages, runs the model, routes tool calls.
|
||||
- **Node host**: executes `system.run`/`system.which` on the node machine.
|
||||
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
|
||||
|
||||
### Start a node host (foreground)
|
||||
|
||||
On the node machine:
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
```
|
||||
|
||||
### Start a node host (service)
|
||||
|
||||
```bash
|
||||
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node service start
|
||||
```
|
||||
|
||||
### Pair + name
|
||||
|
||||
On the gateway host:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
clawdbot nodes list
|
||||
```
|
||||
|
||||
Naming options:
|
||||
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node).
|
||||
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
|
||||
|
||||
### Allowlist the commands
|
||||
|
||||
Exec approvals are **per node host**. Add allowlist entries from the gateway:
|
||||
|
||||
```bash
|
||||
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
|
||||
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
|
||||
```
|
||||
|
||||
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
|
||||
|
||||
### Point exec at the node
|
||||
|
||||
Configure defaults (gateway config):
|
||||
|
||||
```bash
|
||||
clawdbot config set tools.exec.host node
|
||||
clawdbot config set tools.exec.security allowlist
|
||||
clawdbot config set tools.exec.node "<id-or-name>"
|
||||
```
|
||||
|
||||
Or per session:
|
||||
|
||||
```
|
||||
/exec host=node security=allowlist node=<id-or-name>
|
||||
```
|
||||
|
||||
Once set, any `exec` call with `host=node` runs on the node host (subject to the
|
||||
node allowlist/approvals).
|
||||
|
||||
Related:
|
||||
- [Node host CLI](/cli/node)
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
## Invoking commands
|
||||
|
||||
Low-level (raw RPC):
|
||||
@@ -214,6 +289,9 @@ Notes:
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
||||
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
|
||||
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
|
||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||
|
||||
## Mac node mode
|
||||
|
||||
@@ -52,7 +52,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to
|
||||
From the gateway machine:
|
||||
|
||||
```bash
|
||||
dns-sd -B _clawdbot-gateway._tcp local.
|
||||
dns-sd -B _clawdbot-gw._tcp local.
|
||||
```
|
||||
|
||||
More debugging notes: [Bonjour](/gateway/bonjour).
|
||||
@@ -61,7 +61,7 @@ More debugging notes: [Bonjour](/gateway/bonjour).
|
||||
|
||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||
|
||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gateway._tcp` records.
|
||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gw._tcp` records.
|
||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||
|
||||
Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
|
||||
|
||||
@@ -29,17 +29,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.13 \
|
||||
APP_VERSION=2026.1.20 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.13.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.13.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.13 \
|
||||
APP_VERSION=2026.1.20 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.13.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.13.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.20.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.13.zip` (and `Clawdbot-2026.1.13.dSYM.zip`) to the GitHub release for tag `v2026.1.13`.
|
||||
- Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# Clawdbot macOS IPC architecture
|
||||
|
||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
||||
|
||||
## Goals
|
||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||
|
||||
@@ -140,19 +140,27 @@ Safety:
|
||||
- `swift run Clawdbot` (or Xcode)
|
||||
- Package app: `scripts/package-mac-app.sh`
|
||||
|
||||
## Debug gateway discovery (macOS CLI)
|
||||
## Debug gateway connectivity (macOS CLI)
|
||||
|
||||
Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the
|
||||
macOS app uses, without launching the app.
|
||||
Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery
|
||||
logic that the macOS app uses, without launching the app.
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run clawdbot-mac-discovery --timeout 3000 --json
|
||||
swift run clawdbot-mac connect --json
|
||||
swift run clawdbot-mac discover --timeout 3000 --json
|
||||
```
|
||||
|
||||
Options:
|
||||
Connect options:
|
||||
- `--url <ws://host:port>`: override config
|
||||
- `--mode <local|remote>`: resolve from config (default: config or local)
|
||||
- `--probe`: force a fresh health probe
|
||||
- `--timeout <ms>`: request timeout (default: `15000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
Discovery options:
|
||||
- `--include-local`: include gateways that would be filtered as “local”
|
||||
- `--timeout <ms>`: overall discovery window (default `2000`)
|
||||
- `--timeout <ms>`: overall discovery window (default: `2000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
Tip: compare against `clawdbot gateway discover --json` to see whether the
|
||||
|
||||
@@ -49,6 +49,50 @@ Repair/migrate:
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
## Advanced: expose WSL services over LAN (portproxy)
|
||||
|
||||
WSL has its own virtual network. If another machine needs to reach a service
|
||||
running **inside WSL** (SSH, a local TTS server, or the Gateway), you must
|
||||
forward a Windows port to the current WSL IP. The WSL IP changes after restarts,
|
||||
so you may need to refresh the forwarding rule.
|
||||
|
||||
Example (PowerShell **as Administrator**):
|
||||
|
||||
```powershell
|
||||
$Distro = "Ubuntu-24.04"
|
||||
$ListenPort = 2222
|
||||
$TargetPort = 22
|
||||
|
||||
$WslIp = (wsl -d $Distro -- hostname -I).Trim().Split(" ")[0]
|
||||
if (-not $WslIp) { throw "WSL IP not found." }
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort `
|
||||
connectaddress=$WslIp connectport=$TargetPort
|
||||
```
|
||||
|
||||
Allow the port through Windows Firewall (one-time):
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL SSH $ListenPort" -Direction Inbound `
|
||||
-Protocol TCP -LocalPort $ListenPort -Action Allow
|
||||
```
|
||||
|
||||
Refresh the portproxy after WSL restarts:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 | Out-Null
|
||||
netsh interface portproxy add v4tov4 listenport=$ListenPort listenaddress=0.0.0.0 `
|
||||
connectaddress=$WslIp connectport=$TargetPort | Out-Null
|
||||
```
|
||||
|
||||
Notes:
|
||||
- SSH from another machine targets the **Windows host IP** (example: `ssh user@windows-host -p 2222`).
|
||||
- Remote nodes must point at a **reachable** Gateway URL (not `127.0.0.1`); use
|
||||
`clawdbot status --all` to confirm.
|
||||
- Use `listenaddress=0.0.0.0` for LAN access; `127.0.0.1` keeps it local only.
|
||||
- If you want this automatic, register a Scheduled Task to run the refresh
|
||||
step at login.
|
||||
|
||||
## Step-by-step WSL2 install
|
||||
|
||||
### 1) Install WSL2 + Ubuntu
|
||||
|
||||
@@ -41,6 +41,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Nostr](/channels/nostr) — `@clawdbot/nostr`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
|
||||
@@ -34,6 +34,30 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt caching (Anthropic API)
|
||||
|
||||
Clawdbot enables **1-hour prompt caching by default** for Anthropic API keys.
|
||||
This is **API-only**; Claude Code CLI OAuth ignores TTL settings.
|
||||
|
||||
To override the TTL per model, set `cacheControlTtl` in the model `params`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {
|
||||
params: { cacheControlTtl: "5m" } // or "1h"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API
|
||||
requests; keep it if you override provider headers (see [/gateway/configuration](/gateway/configuration)).
|
||||
|
||||
## Option B: Claude Code CLI (setup-token or OAuth)
|
||||
|
||||
**Best for:** using your Claude subscription or existing Claude Code CLI login.
|
||||
|
||||
@@ -32,6 +32,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
## Core concepts
|
||||
|
||||
- [Architecture](/concepts/architecture)
|
||||
- [Network hub](/network)
|
||||
- [Agent runtime](/concepts/agent)
|
||||
- [Agent workspace](/concepts/agent-workspace)
|
||||
- [Memory](/concepts/memory)
|
||||
|
||||
@@ -500,6 +500,7 @@ Notes:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
- `--format aria`: returns the accessibility tree (no refs; inspection only).
|
||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
|
||||
- Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-clawd-managed-browser)).
|
||||
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
|
||||
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
|
||||
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
|
||||
|
||||
@@ -39,7 +39,7 @@ Notes:
|
||||
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.host` (default: `sandbox`)
|
||||
- `tools.exec.security` (default: `deny`)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||
|
||||
@@ -62,3 +62,14 @@ Per-skill fields:
|
||||
- Keys under `entries` map to the skill name by default. If a skill defines
|
||||
`metadata.clawdbot.skillKey`, use that key instead.
|
||||
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
||||
|
||||
### Sandboxed skills + env vars
|
||||
|
||||
When a session is **sandboxed**, skill processes run inside Docker. The sandbox
|
||||
does **not** inherit the host `process.env`.
|
||||
|
||||
Use one of:
|
||||
- `agents.defaults.sandbox.docker.env` (or per-agent `agents.list[].sandbox.docker.env`)
|
||||
- bake the env into your custom sandbox image
|
||||
|
||||
Global `env` and `skills.entries.<skill>.env/apiKey` apply to **host** runs only.
|
||||
|
||||
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
|
||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
|
||||
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download).
|
||||
|
||||
Note on sandboxing:
|
||||
- `requires.bins` is checked on the **host** at skill load time.
|
||||
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
|
||||
|
||||
Notes:
|
||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
||||
- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts.
|
||||
- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform.
|
||||
- Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun).
|
||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||
(Bun is not recommended for WhatsApp/Telegram).
|
||||
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
|
||||
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
|
||||
|
||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||
@@ -155,6 +158,10 @@ Bundled/managed skills can be toggled and supplied with env values:
|
||||
apiKey: "GEMINI_KEY_HERE",
|
||||
env: {
|
||||
GEMINI_API_KEY: "GEMINI_KEY_HERE"
|
||||
},
|
||||
config: {
|
||||
endpoint: "https://example.invalid",
|
||||
model: "nano-pro"
|
||||
}
|
||||
},
|
||||
peekaboo: { enabled: true },
|
||||
@@ -173,6 +180,7 @@ Rules:
|
||||
- `enabled: false` disables the skill even if it’s bundled/installed.
|
||||
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
|
||||
- `config`: optional bag for custom per-skill fields; custom keys must live here.
|
||||
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ Text + native (when enabled):
|
||||
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
|
||||
- `/activation mention|always` (groups only)
|
||||
- `/send on|off|inherit` (owner-only)
|
||||
- `/reset` or `/new`
|
||||
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
|
||||
- `/verbose on|full|off` (alias: `/v`)
|
||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||
@@ -91,6 +91,7 @@ Text-only:
|
||||
|
||||
Notes:
|
||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user