Compare commits
169 Commits
fix/cli-qu
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01aa7eba21 | ||
|
|
9479fded34 | ||
|
|
b969c216fc | ||
|
|
f51a4d2aca | ||
|
|
30b3a9de30 | ||
|
|
0391f6553b | ||
|
|
d0c986c4f0 | ||
|
|
384028e12e | ||
|
|
4c14d6c8db | ||
|
|
6fa437613b | ||
|
|
949fa1051f | ||
|
|
4965727f39 | ||
|
|
7c34883267 | ||
|
|
072c3dc55c | ||
|
|
83b3875131 | ||
|
|
a35083808c | ||
|
|
3d3ec9d972 | ||
|
|
9838a2850f | ||
|
|
6c6bc6ff1c | ||
|
|
1791c1a765 | ||
|
|
6e53c061ff | ||
|
|
1773f8aea2 | ||
|
|
52bdf57743 | ||
|
|
b3ab24eb8e | ||
|
|
6ac1c1d6ea | ||
|
|
7655a501d0 | ||
|
|
3b1b14b0b1 | ||
|
|
bf15c87d2b | ||
|
|
2dae4d382f | ||
|
|
e9a47a02d1 | ||
|
|
929666a8c8 | ||
|
|
cd409e5667 | ||
|
|
8e80823b03 | ||
|
|
319afd192d | ||
|
|
6e0daf0936 | ||
|
|
d0cb4e092f | ||
|
|
f5a881c99d | ||
|
|
66377fc030 | ||
|
|
d8d295b0b3 | ||
|
|
ef36e24522 | ||
|
|
d43d4fcced | ||
|
|
d42b69df74 | ||
|
|
1ec1f6dcbf | ||
|
|
e96b939732 | ||
|
|
e479c870fd | ||
|
|
f2db894685 | ||
|
|
4f03283126 | ||
|
|
ed7dec0975 | ||
|
|
fbb3da506f | ||
|
|
dfa6c5c2b3 | ||
|
|
028eed5fe8 | ||
|
|
2b16a87f04 | ||
|
|
731049936d | ||
|
|
5a5b058ba0 | ||
|
|
72f28be648 | ||
|
|
ff645524d8 | ||
|
|
c534390bc0 | ||
|
|
c5003e5441 | ||
|
|
0b3ebb0c63 | ||
|
|
23981496f9 | ||
|
|
f2e425dc2b | ||
|
|
e48d68bbc7 | ||
|
|
842fc8d08b | ||
|
|
54ec14262b | ||
|
|
d0c70178e0 | ||
|
|
3bc9c330eb | ||
|
|
b7fcc8584f | ||
|
|
2b8ce3f06b | ||
|
|
41d44021e7 | ||
|
|
1ab1e312b2 | ||
|
|
fa9aafce83 | ||
|
|
0d5dec4c66 | ||
|
|
b2d5889f6e | ||
|
|
2ee71e4154 | ||
|
|
1656f491fd | ||
|
|
a057b3c9e8 | ||
|
|
236b27cb3a | ||
|
|
1634abf293 | ||
|
|
ca9688b5cc | ||
|
|
e6364d031d | ||
|
|
01c8d099ad | ||
|
|
f6e619f078 | ||
|
|
b2b331230b | ||
|
|
c0c9742e44 | ||
|
|
abcca86e4e | ||
|
|
a5d8f89b53 | ||
|
|
a0d2a7232e | ||
|
|
f449115ec5 | ||
|
|
16bc4cdef3 | ||
|
|
23e4ba845c | ||
|
|
d9f9e93dee | ||
|
|
dae34f3a61 | ||
|
|
af61b353a4 | ||
|
|
29476b222d | ||
|
|
e7c16cc0e6 | ||
|
|
3dddbe1053 | ||
|
|
2dea6bfa7e | ||
|
|
04e3bfed35 | ||
|
|
3e32050601 | ||
|
|
7fb45ed9b8 | ||
|
|
b7ba94f0c1 | ||
|
|
5b827528f8 | ||
|
|
409e33d9c2 | ||
|
|
747277d914 | ||
|
|
068dca3366 | ||
|
|
336a1ad9cf | ||
|
|
d42f767d0c | ||
|
|
9b8ae62399 | ||
|
|
7c38b535f6 | ||
|
|
af370ab23e | ||
|
|
1210657fda | ||
|
|
12afec953f | ||
|
|
8e2707e232 | ||
|
|
8befe7f8a7 | ||
|
|
a70fcc8ae0 | ||
|
|
fa521154ff | ||
|
|
dffc1a4dcd | ||
|
|
61e385b331 | ||
|
|
f8f319713f | ||
|
|
509215e935 | ||
|
|
f726656d1e | ||
|
|
30d3e1da21 | ||
|
|
bb665bf22c | ||
|
|
f17dcb6213 | ||
|
|
d54c101100 | ||
|
|
96daa51d45 | ||
|
|
6084421ec6 | ||
|
|
bca5c0d569 | ||
|
|
8c3cdba21c | ||
|
|
e274b5a040 | ||
|
|
a139d35fa2 | ||
|
|
8312a19f02 | ||
|
|
fe8b28cdd9 | ||
|
|
10eb1beccf | ||
|
|
a4b347b454 | ||
|
|
688a0ce439 | ||
|
|
6146acbb69 | ||
|
|
35492f8513 | ||
|
|
db9be87d94 | ||
|
|
4a99b9b651 | ||
|
|
7dea403302 | ||
|
|
10731dfee3 | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
d0455f2683 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -60,6 +60,9 @@
|
||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||
|
||||
## Shorthand Commands
|
||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
### PR Workflow (Review vs Land)
|
||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
||||
@@ -113,6 +116,14 @@
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
|
||||
196
CHANGELOG.md
196
CHANGELOG.md
@@ -2,7 +2,71 @@
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
|
||||
- TBD
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
|
||||
### Changes
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
|
||||
### Fixes
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
@@ -13,98 +77,56 @@
|
||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||
|
||||
### Changes
|
||||
#### Web Tools
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing.
|
||||
- Tools: enable `web_fetch` by default (unless explicitly disabled in config).
|
||||
- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips.
|
||||
|
||||
#### Browser / Control UI
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`.
|
||||
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
|
||||
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
|
||||
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
|
||||
|
||||
#### Plugins
|
||||
- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.
|
||||
- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.
|
||||
|
||||
#### Security
|
||||
- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy.
|
||||
- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths.
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
|
||||
#### Onboarding / Daemon
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
|
||||
|
||||
#### Auth / Usage / Config
|
||||
- Usage: add MiniMax coding plan usage tracking.
|
||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight.
|
||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
||||
|
||||
#### Channels
|
||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
||||
- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz.
|
||||
|
||||
#### Docs
|
||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
||||
- Security: add `SECURITY.md` reporting policy.
|
||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
|
||||
- Docs: add multi-gateway guide (sidebar + nav).
|
||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
||||
|
||||
### Fixes
|
||||
|
||||
#### Gateway / Daemon / Sessions
|
||||
- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.
|
||||
- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias).
|
||||
- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.
|
||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance.
|
||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
|
||||
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
|
||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
||||
- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.
|
||||
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
|
||||
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
#### CLI / Onboarding
|
||||
- Onboarding: show web search setup at the end (not the beginning).
|
||||
- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.
|
||||
- Health: colorize “not configured” provider lines for easier scanning.
|
||||
## 2026.1.14
|
||||
|
||||
#### Control UI / TUI
|
||||
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)
|
||||
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.
|
||||
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard).
|
||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
||||
### Changes
|
||||
- Usage: add MiniMax coding plan usage tracking.
|
||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
||||
|
||||
### Fixes
|
||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||
|
||||
#### Agents / Auth / Tools / Sandbox
|
||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||
@@ -123,34 +145,17 @@
|
||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||
- macOS: update cron testing channel arg. (#896) — thanks @ngutman.
|
||||
|
||||
#### Channels / Messaging
|
||||
- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
||||
- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.
|
||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.
|
||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
||||
- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06.
|
||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
||||
- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)
|
||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
||||
- WhatsApp: harden owner command auth.
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
|
||||
#### Config / Doctor / Packaging
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).
|
||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
||||
- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output.
|
||||
|
||||
## 2026.1.13
|
||||
|
||||
@@ -163,6 +168,7 @@
|
||||
### Fixes
|
||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
||||
|
||||
## 2026.1.12-1
|
||||
|
||||
|
||||
2
Peekaboo
2
Peekaboo
Submodule Peekaboo updated: 95ad7532c1...5c195f5e46
37
README.md
37
README.md
@@ -474,22 +474,23 @@ 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/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
|
||||
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
|
||||
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a>
|
||||
<a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
|
||||
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
|
||||
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a>
|
||||
<a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
|
||||
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
@@ -119,7 +119,7 @@ dependencies {
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.bridge.BridgePairingClient
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.LocationCaptureManager
|
||||
import com.clawdbot.android.BuildConfig
|
||||
@@ -160,6 +161,9 @@ class NodeRuntime(context: Context) {
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
@@ -488,12 +492,17 @@ class NodeRuntime(context: Context) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello = buildPairingHello(token = null),
|
||||
tls = tls,
|
||||
onTlsFingerprint = { fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
@@ -510,6 +519,7 @@ class NodeRuntime(context: Context) {
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
tls = tls,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -556,6 +566,41 @@ class NodeRuntime(context: Context) {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (hinted) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
return BridgeTlsParams(
|
||||
required = false,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = true,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
|
||||
@@ -147,6 +147,16 @@ class SecurePrefs(context: Context) {
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
||||
val key = "bridge.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "bridge.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
@@ -143,6 +143,8 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -155,6 +157,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
@@ -209,6 +213,11 @@ class BridgeDiscovery(
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
@@ -252,6 +261,8 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -264,6 +275,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -474,6 +487,11 @@ class BridgeDiscovery(
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
|
||||
@@ -10,6 +10,8 @@ data class BridgeEndpoint(
|
||||
val gatewayPort: Int? = null,
|
||||
val bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class BridgePairingClient {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -33,95 +32,120 @@ class BridgePairingClient {
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
suspend fun pairAndHello(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams? = null,
|
||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
||||
): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
if (tls != null) {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
||||
} catch (e: Exception) {
|
||||
if (tls.required) throw e
|
||||
}
|
||||
}
|
||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
||||
}
|
||||
|
||||
private fun pairAndHelloWithTls(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams?,
|
||||
onTlsFingerprint: ((String) -> Unit)?,
|
||||
): PairResult {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
return when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
return PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -35,6 +35,7 @@ class BridgeSession(
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
@@ -66,11 +67,17 @@ class BridgeSession(
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private data class DesiredConnection(
|
||||
val endpoint: BridgeEndpoint,
|
||||
val hello: Hello,
|
||||
val tls: BridgeTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
||||
desired = DesiredConnection(endpoint, hello, tls)
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
@@ -78,7 +85,7 @@ class BridgeSession(
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
desired = target.copy(hello = hello)
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
@@ -165,10 +172,10 @@ class BridgeSession(
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello) = target
|
||||
val (endpoint, hello, tls) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello)
|
||||
connectOnce(endpoint, hello, tls)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
@@ -192,60 +199,76 @@ class BridgeSession(
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
if (tls != null) {
|
||||
try {
|
||||
connectWithSocket(endpoint, hello, tls)
|
||||
return@withContext
|
||||
} catch (err: Throwable) {
|
||||
if (tls.required) throw err
|
||||
}
|
||||
}
|
||||
connectWithSocket(endpoint, hello, null)
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||
when (frame["type"].asStringOrNull()) {
|
||||
"event" -> {
|
||||
val event = frame["event"].asStringOrNull() ?: continue
|
||||
val payload = frame["payloadJSON"].asStringOrNull()
|
||||
onEvent(event, payload)
|
||||
}
|
||||
"ping" -> {
|
||||
val id = frame["id"].asStringOrNull() ?: ""
|
||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||
@@ -291,20 +314,20 @@ class BridgeSession(
|
||||
},
|
||||
)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
"invoke-res" -> {
|
||||
// gateway->node only (ignore)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
} finally {
|
||||
currentConnection = null
|
||||
for ((_, waiter) in pending) {
|
||||
waiter.cancel()
|
||||
}
|
||||
pending.clear()
|
||||
conn.closeQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||
buildJsonObject {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class BridgeTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
||||
if (params == null) return Socket()
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
return context.socketFactory.createSocket()
|
||||
}
|
||||
|
||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
||||
if (socket is SSLSocket) {
|
||||
socket.startHandshake()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -10,10 +10,36 @@ actor BridgeClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
@@ -142,6 +168,18 @@ actor BridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private struct TimeoutError: LocalizedError, Sendable {
|
||||
var purpose: String
|
||||
var seconds: Int
|
||||
|
||||
@@ -10,6 +10,7 @@ protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
@@ -115,9 +116,12 @@ final class BridgeConnectionController {
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
@@ -135,10 +139,12 @@ final class BridgeConnectionController {
|
||||
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
@@ -182,6 +188,7 @@ final class BridgeConnectionController {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
@@ -193,6 +200,7 @@ final class BridgeConnectionController {
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
@@ -208,6 +216,7 @@ final class BridgeConnectionController {
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
tls: tls,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -217,6 +226,47 @@ final class BridgeConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
@@ -23,6 +23,8 @@ final class BridgeDiscoveryModel {
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
@@ -90,6 +92,8 @@ final class BridgeDiscoveryModel {
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
@@ -214,4 +218,9 @@ final class BridgeDiscoveryModel {
|
||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,15 +69,42 @@ actor BridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -255,6 +282,18 @@ actor BridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func timeoutRPC(id: String) async {
|
||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
|
||||
66
apps/ios/Sources/Bridge/BridgeTLS.swift
Normal file
66
apps/ios/Sources/Bridge/BridgeTLS.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
struct BridgeTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum BridgeTLSStore {
|
||||
private static let service = "com.clawdbot.bridge.tls"
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
||||
}
|
||||
}
|
||||
|
||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
||||
guard let params else { return nil }
|
||||
let options = NWProtocolTLS.Options()
|
||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
||||
let allowTOFU = params.allowTOFU
|
||||
let storeKey = params.storeKey
|
||||
|
||||
sec_protocol_options_set_verify_block(
|
||||
options.securityProtocolOptions,
|
||||
{ _, trust, complete in
|
||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
{
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let fingerprint = sha256Hex(data)
|
||||
if let expected {
|
||||
complete(fingerprint == expected)
|
||||
return
|
||||
}
|
||||
if allowTOFU {
|
||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
||||
complete(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||
complete(ok)
|
||||
},
|
||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter { $0.isHexDigit }
|
||||
}
|
||||
@@ -190,14 +190,7 @@ actor CameraController {
|
||||
}
|
||||
|
||||
func listDevices() -> [CameraDeviceInfo] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices.map { device in
|
||||
return Self.discoverVideoDevices().map { device in
|
||||
CameraDeviceInfo(
|
||||
id: device.uniqueID,
|
||||
name: device.localizedName,
|
||||
@@ -232,7 +225,7 @@ actor CameraController {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -252,6 +245,24 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||
let types: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
.builtInUltraWideCamera,
|
||||
.builtInTelephotoCamera,
|
||||
.builtInDualCamera,
|
||||
.builtInDualWideCamera,
|
||||
.builtInTripleCamera,
|
||||
.builtInTrueDepthCamera,
|
||||
.builtInLiDARDepthCamera,
|
||||
]
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: types,
|
||||
mediaType: .video,
|
||||
position: .unspecified)
|
||||
return session.devices
|
||||
}
|
||||
|
||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||
let q = quality ?? 0.9
|
||||
return min(1.0, max(0.05, q))
|
||||
|
||||
@@ -205,6 +205,7 @@ final class NodeAppModel {
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
@@ -232,6 +233,7 @@ final class NodeAppModel {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
|
||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||
{
|
||||
{ sample, type, error in
|
||||
let sampleBox = UncheckedSendableBox(value: sample)
|
||||
// ReplayKit can call the capture handler on a background queue.
|
||||
// Serialize writes to avoid queue asserts.
|
||||
recordQueue.async {
|
||||
let sample = sampleBox.value
|
||||
if let error {
|
||||
state.withLock { state in
|
||||
if state.handlerError == nil { state.handlerError = error }
|
||||
|
||||
@@ -407,9 +407,11 @@ struct SettingsTab: View {
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -426,6 +428,7 @@ struct SettingsTab: View {
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -462,6 +465,8 @@ struct SettingsTab: View {
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
@@ -485,6 +490,7 @@ struct SettingsTab: View {
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -500,7 +506,8 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -517,6 +524,47 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
|
||||
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||
|
||||
@@ -27,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
@@ -244,6 +245,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||
let account = "bridge-token.ios-test"
|
||||
@@ -292,6 +295,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway B",
|
||||
@@ -303,6 +308,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 28789,
|
||||
bridgePort: 28790,
|
||||
canvasPort: 28793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
||||
options:
|
||||
bundleIdPrefix: com.clawdbot
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
@@ -73,6 +73,15 @@
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -144,6 +153,24 @@
|
||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -307,8 +307,8 @@ enum AgentWorkspace {
|
||||
}
|
||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
urls.append(cwd.appendingPathComponent("docs")
|
||||
.appendingPathComponent(self.templateDirname)
|
||||
.appendingPathComponent(named))
|
||||
.appendingPathComponent(self.templateDirname)
|
||||
.appendingPathComponent(named))
|
||||
return urls
|
||||
}
|
||||
|
||||
|
||||
@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
|
||||
@@ -170,6 +170,10 @@ final class AppState {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
var systemRunPolicy: SystemRunPolicy {
|
||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
||||
}
|
||||
|
||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||
var canvasPanelVisible: Bool = false
|
||||
|
||||
@@ -264,8 +268,8 @@ final class AppState {
|
||||
}
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
@@ -292,6 +296,7 @@ final class AppState {
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.systemRunPolicy = SystemRunPolicy.load()
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
if !self.isPreview {
|
||||
@@ -336,6 +341,15 @@ final class AppState {
|
||||
return host
|
||||
}
|
||||
|
||||
private static func sanitizeSSHTarget(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("ssh ") {
|
||||
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func startConfigWatcher() {
|
||||
let configUrl = ClawdbotConfigFile.url()
|
||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||
@@ -356,8 +370,8 @@ final class AppState {
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -401,6 +415,7 @@ final class AppState {
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -430,15 +445,46 @@ final class AppState {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote, let host = remoteHost {
|
||||
if connectionMode == .remote {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
var remoteChanged = false
|
||||
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -182,12 +182,12 @@ actor BridgeServer {
|
||||
?? "main"
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last))
|
||||
message: text,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
@@ -208,12 +208,12 @@ actor BridgeServer {
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel))
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
|
||||
let actionId =
|
||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? UUID().uuidString
|
||||
?? UUID().uuidString
|
||||
|
||||
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||
|
||||
|
||||
@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
|
||||
@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
guard let url = CanvasScheme.makeURL(
|
||||
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||
path: trimmed)
|
||||
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||
path: trimmed)
|
||||
else {
|
||||
canvasWindowLogger
|
||||
.error(
|
||||
|
||||
@@ -125,13 +125,13 @@ enum CommandResolver {
|
||||
|
||||
// fnm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
||||
suffix: "installation/bin"))
|
||||
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
||||
suffix: "installation/bin"))
|
||||
|
||||
// nvm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".nvm/versions/node"),
|
||||
suffix: "bin"))
|
||||
base: home.appendingPathComponent(".nvm/versions/node"),
|
||||
suffix: "bin"))
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ struct ConfigSettings: View {
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
private static let browserProfileNote =
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@State private var hasLoaded = false
|
||||
@@ -97,8 +97,8 @@ extension ConfigSettings {
|
||||
Text("Clawdbot CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.isNixMode
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -753,9 +753,9 @@ extension ConfigSettings {
|
||||
do {
|
||||
let res: ModelsListResult =
|
||||
try await GatewayConnection.shared
|
||||
.requestDecoded(
|
||||
method: .modelsList,
|
||||
timeoutMs: 15000)
|
||||
.requestDecoded(
|
||||
method: .modelsList,
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
} catch {
|
||||
@@ -792,8 +792,8 @@ extension ConfigSettings {
|
||||
choice.provider,
|
||||
self.modelRef(for: choice),
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
return tokens.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
} catch {
|
||||
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -377,18 +377,18 @@ extension ConnectionsSettings {
|
||||
case .telegram:
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
return self
|
||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
.lastProbeAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ extension ConnectionsStore {
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?[
|
||||
"ui",
|
||||
]?.dictionaryValue
|
||||
]?.dictionaryValue
|
||||
let rawSeam = ui?[
|
||||
"seamColor",
|
||||
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||
let systemRunPolicyKey = "clawdbot.systemRunPolicy"
|
||||
let systemRunAllowlistKey = "clawdbot.systemRunAllowlist"
|
||||
let systemRunEnabledKey = "clawdbot.systemRunEnabled"
|
||||
let locationModeKey = "clawdbot.locationMode"
|
||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||
|
||||
@@ -189,20 +189,20 @@ enum CritterIconRenderer {
|
||||
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.bodyRect,
|
||||
cornerWidth: geometry.bodyCorner,
|
||||
cornerHeight: geometry.bodyCorner,
|
||||
transform: nil))
|
||||
roundedRect: geometry.bodyRect,
|
||||
cornerWidth: geometry.bodyCorner,
|
||||
cornerHeight: geometry.bodyCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.leftEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
roundedRect: geometry.leftEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.rightEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
roundedRect: geometry.rightEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
|
||||
for i in 0..<4 {
|
||||
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
|
||||
width: geometry.legW,
|
||||
height: geometry.legH * geometry.legHeightScale)
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rect,
|
||||
cornerWidth: geometry.legW * 0.34,
|
||||
cornerHeight: geometry.legW * 0.34,
|
||||
transform: nil))
|
||||
roundedRect: rect,
|
||||
cornerWidth: geometry.legW * 0.34,
|
||||
cornerHeight: geometry.legW * 0.34,
|
||||
transform: nil))
|
||||
}
|
||||
canvas.context.fillPath()
|
||||
}
|
||||
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
|
||||
height: holeH)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if options.eyesClosedLines {
|
||||
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
|
||||
width: lineW,
|
||||
height: lineH)
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
roundedRect: leftRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
roundedRect: rightRect,
|
||||
cornerWidth: corner,
|
||||
cornerHeight: corner,
|
||||
transform: nil))
|
||||
} else {
|
||||
let eyeOpen = max(0.05, 1 - options.blink)
|
||||
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y)))
|
||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y)))
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
canvas.context.addPath(left)
|
||||
|
||||
@@ -121,12 +121,12 @@ extension CritterStatusLabel {
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
badge: badge))
|
||||
blink: self.blinkAmount,
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
badge: badge))
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
|
||||
@@ -188,9 +188,15 @@ extension CronJobEditor {
|
||||
}
|
||||
}
|
||||
|
||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||
if self.scheduleKind == .at {
|
||||
root["deleteAfterRun"] = self.deleteAfterRun
|
||||
func applyDeleteAfterRun(
|
||||
to root: inout [String: Any],
|
||||
scheduleKind: ScheduleKind? = nil,
|
||||
deleteAfterRun: Bool? = nil)
|
||||
{
|
||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
||||
if resolvedSchedule == .at {
|
||||
root["deleteAfterRun"] = resolvedDelete
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
@@ -11,15 +11,15 @@ struct CronJobEditor: View {
|
||||
let labelColumnWidth: CGFloat = 160
|
||||
static let introText =
|
||||
"Create a schedule that wakes clawd via the Gateway. "
|
||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||
static let sessionTargetNote =
|
||||
"Main jobs post a system event into the current main session. "
|
||||
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||
static let scheduleKindNote =
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
static let isolatedPayloadNote =
|
||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
static let mainSummaryNote =
|
||||
|
||||
@@ -70,13 +70,13 @@ enum CronSchedule: Codable, Equatable {
|
||||
enum CronPayload: Codable, Equatable {
|
||||
case systemEvent(text: String)
|
||||
case agentTurn(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
message: String,
|
||||
thinking: String?,
|
||||
timeoutSeconds: Int?,
|
||||
deliver: Bool?,
|
||||
channel: String?,
|
||||
to: String?,
|
||||
bestEffortDeliver: Bool?)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
||||
|
||||
@@ -26,8 +26,8 @@ extension CronSettings {
|
||||
})
|
||||
}
|
||||
.alert("Delete cron job?", isPresented: Binding(
|
||||
get: { self.confirmDelete != nil },
|
||||
set: { if !$0 { self.confirmDelete = nil } }))
|
||||
get: { self.confirmDelete != nil },
|
||||
set: { if !$0 { self.confirmDelete = nil } }))
|
||||
{
|
||||
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
@@ -42,9 +42,9 @@ extension CronSettings {
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
var schedulerBanner: some View {
|
||||
|
||||
@@ -69,8 +69,8 @@ extension CronSettings {
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Enabled", isOn: Binding(
|
||||
get: { job.enabled },
|
||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||
get: { job.enabled },
|
||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||
|
||||
@@ -102,8 +102,8 @@ enum DebugActions {
|
||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
} catch {
|
||||
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
@@ -127,8 +127,8 @@ enum DebugActions {
|
||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
await HealthStore.shared.refresh(onDemand: true)
|
||||
return .success("SSH tunnel reset.")
|
||||
} catch {
|
||||
|
||||
@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
|
||||
|
||||
private static func loadMapping(resourceName: String) -> [String: String] {
|
||||
guard let url = self.resourceBundle?.url(
|
||||
forResource: resourceName,
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory)
|
||||
forResource: resourceName,
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory)
|
||||
else { return [:] }
|
||||
|
||||
do {
|
||||
|
||||
@@ -113,17 +113,17 @@ actor GatewayChannelActor {
|
||||
self.task = nil
|
||||
|
||||
await self.failPending(NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -433,14 +433,14 @@ extension GatewayConnection {
|
||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||
{
|
||||
await self.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
idempotencyKey: idempotencyKey))
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ params: [String: AnyCodable]) async {
|
||||
|
||||
@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowBackground(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)))
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
|
||||
@@ -206,10 +206,10 @@ actor GatewayEndpointStore {
|
||||
let port = self.deps.localPort()
|
||||
let host = await self.deps.localHost()
|
||||
self.setState(.ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://\(host):\(port)")!,
|
||||
token: token,
|
||||
password: password))
|
||||
mode: .local,
|
||||
url: URL(string: "ws://\(host):\(port)")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
@@ -219,10 +219,10 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .unconfigured:
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
|
||||
@@ -2,77 +2,16 @@ import Foundation
|
||||
|
||||
enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private enum GatewayProgramArgumentsError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(
|
||||
port: Int,
|
||||
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||
{
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
#if DEBUG
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||
switch CommandResolver.runtimeResolution() {
|
||||
case let .success(runtime):
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||
{
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
}
|
||||
|
||||
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
@@ -81,255 +20,44 @@ enum GatewayLaunchAgentManager {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if enabled {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
|
||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||
let desiredToken = self.preferredGatewayToken()
|
||||
let desiredPassword = self.preferredGatewayPassword()
|
||||
let desiredConfig = DesiredConfig(
|
||||
port: port,
|
||||
bind: desiredBind,
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||
guard case let .success(programArguments) = programArgumentsResult else {
|
||||
if case let .failure(error) = programArgumentsResult {
|
||||
let message = error.localizedDescription
|
||||
self.logger.error("launchd enable failed: \(message)")
|
||||
return message
|
||||
}
|
||||
return "Failed to resolve gateway command."
|
||||
}
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded {
|
||||
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
self.writePlist(programArguments: programArguments)
|
||||
|
||||
await self.ensureEnabled()
|
||||
if loaded {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
if bootstrap.status != 0 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
await self.ensureEnabled()
|
||||
return nil
|
||||
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||
return await self.runDaemonCommand([
|
||||
"install",
|
||||
"--force",
|
||||
"--port",
|
||||
"\(port)",
|
||||
"--runtime",
|
||||
"node",
|
||||
])
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
await self.ensureDisabled()
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
self.logger.info("launchd disable requested via CLI")
|
||||
return await self.runDaemonCommand(["uninstall"])
|
||||
}
|
||||
|
||||
static func kickstart() async {
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(programArguments: [String]) {
|
||||
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let argsXml = programArguments
|
||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||
.joined(separator: "\n ")
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>\(gatewayLaunchdLabel)</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
\(argsXml)
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
\(envEntries)
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
do {
|
||||
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayToken() -> String? {
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct DesiredConfig: Equatable {
|
||||
let port: Int
|
||||
let bind: String
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private struct InstalledConfig: Equatable {
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
|
||||
func matches(_ desired: DesiredConfig) -> Bool {
|
||||
guard self.port == desired.port else { return false }
|
||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||
guard self.token == desired.token else { return false }
|
||||
guard self.password == desired.password else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readPlistConfig() -> InstalledConfig? {
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||
return InstalledConfig(
|
||||
port: snapshot.port,
|
||||
bind: snapshot.bind,
|
||||
token: snapshot.token,
|
||||
password: snapshot.password)
|
||||
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||
}
|
||||
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||
}
|
||||
|
||||
private static func ensureEnabled() async {
|
||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd enable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd enable failed: \(msg)")
|
||||
static func launchdGatewayLogPath() -> String {
|
||||
let snapshot = self.launchdConfigSnapshot()
|
||||
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stdout.isEmpty
|
||||
{
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDisabled() async {
|
||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd disable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd disable failed: \(msg)")
|
||||
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stderr.isEmpty
|
||||
{
|
||||
return stderr
|
||||
}
|
||||
return LogLocator.launchdGatewayLogPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,20 +67,102 @@ extension GatewayLaunchAgentManager {
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager.default.fileExists(atPath: marker.path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
timeout: 15,
|
||||
quiet: true)
|
||||
guard result.success, let payload = result.payload else { return nil }
|
||||
guard
|
||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||
let service = json["service"] as? [String: Any],
|
||||
let loaded = service["loaded"] as? Bool
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
private struct ParsedDaemonJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
}
|
||||
|
||||
private static func runDaemonCommand(
|
||||
_ args: [String],
|
||||
timeout: Double = 15,
|
||||
quiet: Bool = false) async -> String?
|
||||
{
|
||||
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
||||
if result.success { return nil }
|
||||
return result.message ?? "Gateway daemon command failed"
|
||||
}
|
||||
|
||||
private static func runDaemonCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
||||
let ok = parsed?.object["ok"] as? Bool
|
||||
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
||||
?? "Gateway daemon command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail)
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return ParsedDaemonJson(text: jsonText, object: object)
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
|
||||
}
|
||||
|
||||
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
|
||||
-> T?
|
||||
-> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try self.decode(payload, as: T.self)
|
||||
|
||||
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||
switch self.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return
|
||||
case .stopped, .failed:
|
||||
break
|
||||
}
|
||||
self.status = .starting
|
||||
self.logger.debug("gateway start requested")
|
||||
|
||||
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func refreshLog() {
|
||||
guard self.logRefreshTask == nil else { return }
|
||||
let path = LogLocator.launchdGatewayLogPath
|
||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||
let limit = self.logLimit
|
||||
self.logRefreshTask = Task { [weak self] in
|
||||
let log = await Task.detached(priority: .utility) {
|
||||
@@ -354,7 +362,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,28 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$state.systemRunPolicy) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text("""
|
||||
Controls remote command execution on this Mac when it is paired as a node. \
|
||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||
"Never" disables `system.run`.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
@@ -92,7 +114,8 @@ struct GeneralSettings: View {
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
@@ -238,7 +261,7 @@ struct GeneralSettings: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
@@ -627,8 +650,8 @@ extension GeneralSettings {
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
let data = try await ControlChannel.shared.health(timeout: 10)
|
||||
if decodeHealthSnapshot(from: data) != nil {
|
||||
self.remoteStatus = .ok
|
||||
|
||||
@@ -13,7 +13,7 @@ enum InstanceIdentity {
|
||||
let defaults = Self.defaults
|
||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
||||
host: "phone",
|
||||
ip: "10.0.0.3",
|
||||
version: "2.0.0",
|
||||
platform: "iOS 17.2",
|
||||
platform: "iOS 18.0",
|
||||
deviceFamily: "iPhone",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 35,
|
||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
||||
_ = view.platformIcon("watchOS 10")
|
||||
_ = view.platformIcon("unknown 1.0")
|
||||
_ = view.prettyPlatform("macOS 14.2")
|
||||
_ = view.prettyPlatform("iOS 17")
|
||||
_ = view.prettyPlatform("iOS 18")
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
|
||||
@@ -31,6 +31,8 @@ enum Launchctl {
|
||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||
let programArguments: [String]
|
||||
let environment: [String: String]
|
||||
let stdoutPath: String?
|
||||
let stderrPath: String?
|
||||
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
@@ -53,6 +55,10 @@ enum LaunchAgentPlist {
|
||||
guard let root = rootAny as? [String: Any] else { return nil }
|
||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||
let stdoutPath = (root["StandardOutPath"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let stderrPath = (root["StandardErrorPath"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
@@ -60,6 +66,8 @@ enum LaunchAgentPlist {
|
||||
return LaunchAgentPlistSnapshot(
|
||||
programArguments: programArguments,
|
||||
environment: env,
|
||||
stdoutPath: stdoutPath,
|
||||
stderrPath: stderrPath,
|
||||
port: port,
|
||||
bind: bind,
|
||||
token: token,
|
||||
|
||||
@@ -30,9 +30,9 @@ enum LogLocator {
|
||||
self.ensureLogDirExists()
|
||||
let fm = FileManager.default
|
||||
let files = (try? fm.contentsOfDirectory(
|
||||
at: self.logDir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles])) ?? []
|
||||
at: self.logDir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles])) ?? []
|
||||
|
||||
return files
|
||||
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
|
||||
|
||||
81
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
81
apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum MacNodeConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
||||
|
||||
static func url() -> URL {
|
||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
||||
}
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
self.logger.warning("mac node config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
} catch {
|
||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
let raw = systemRun?["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
systemRun["policy"] = policy.rawValue
|
||||
root["systemRun"] = systemRun
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func systemRunAllowlist() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
return systemRun?["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
if systemRun.isEmpty {
|
||||
root.removeValue(forKey: "systemRun")
|
||||
} else {
|
||||
root["systemRun"] = systemRun
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ struct MenuContent: View {
|
||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||
}
|
||||
|
||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
||||
Binding(
|
||||
get: { self.state.systemRunPolicy },
|
||||
set: { self.state.systemRunPolicy = $0 })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
@@ -68,6 +74,13 @@ struct MenuContent: View {
|
||||
Toggle(isOn: self.$cameraEnabled) {
|
||||
Label("Allow Camera", systemImage: "camera")
|
||||
}
|
||||
Picker(selection: self.systemRunPolicyBinding) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
} label: {
|
||||
Label("Node Run Commands", systemImage: "terminal")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
let initialWidth = self.initialCardWidth(for: menu)
|
||||
|
||||
let initial = AnyView(ContextMenuCardView(
|
||||
rows: initialRows,
|
||||
statusText: initialStatusText,
|
||||
isLoading: initialIsLoading))
|
||||
rows: initialRows,
|
||||
statusText: initialStatusText,
|
||||
isLoading: initialIsLoading))
|
||||
|
||||
let hosting = NSHostingView(rootView: initial)
|
||||
hosting.frame.size.width = max(1, initialWidth)
|
||||
|
||||
@@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Injection
|
||||
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
@@ -138,8 +139,8 @@ extension MenuSessionsInjector {
|
||||
headerItem.isEnabled = false
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -175,8 +176,8 @@ extension MenuSessionsInjector {
|
||||
: self.controlChannelStatusText(for: channelState)
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
statusText: statusText)),
|
||||
count: 0,
|
||||
statusText: statusText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -299,7 +300,7 @@ extension MenuSessionsInjector {
|
||||
headerItem.isEnabled = false
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuUsageHeaderView(
|
||||
count: rows.count)),
|
||||
count: rows.count)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
@@ -472,11 +473,11 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
@@ -596,10 +597,10 @@ extension MenuSessionsInjector {
|
||||
let width = self.submenuWidth()
|
||||
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: row.key,
|
||||
title: "Recent messages (last 10)",
|
||||
width: width,
|
||||
maxLines: 3))
|
||||
sessionKey: row.key,
|
||||
title: "Recent messages (last 10)",
|
||||
width: width,
|
||||
maxLines: 3))
|
||||
|
||||
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
|
||||
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
|
||||
@@ -703,10 +704,10 @@ extension MenuSessionsInjector {
|
||||
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: sessionKey,
|
||||
title: "Recent messages (expanded)",
|
||||
width: width,
|
||||
maxLines: 8))
|
||||
sessionKey: sessionKey,
|
||||
title: "Recent messages (expanded)",
|
||||
width: width,
|
||||
maxLines: 8))
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -763,9 +764,9 @@ extension MenuSessionsInjector {
|
||||
!commands.isEmpty
|
||||
{
|
||||
menu.addItem(self.makeNodeMultilineItem(
|
||||
label: "Commands",
|
||||
value: commands.joined(separator: ", "),
|
||||
width: width))
|
||||
label: "Commands",
|
||||
value: commands.joined(separator: ", "),
|
||||
width: width))
|
||||
}
|
||||
|
||||
return menu
|
||||
@@ -855,9 +856,9 @@ extension MenuSessionsInjector {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Reset session?",
|
||||
message: "Starts a new session id for “\(key)”.",
|
||||
action: "Reset")
|
||||
title: "Reset session?",
|
||||
message: "Starts a new session id for “\(key)”.",
|
||||
action: "Reset")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
@@ -874,9 +875,9 @@ extension MenuSessionsInjector {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Compact session log?",
|
||||
message: "Keeps the last 400 lines; archives the old file.",
|
||||
action: "Compact")
|
||||
title: "Compact session log?",
|
||||
message: "Keeps the last 400 lines; archives the old file.",
|
||||
action: "Compact")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
@@ -893,9 +894,9 @@ extension MenuSessionsInjector {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Delete session?",
|
||||
message: "Deletes the “\(key)” entry and archives its transcript.",
|
||||
action: "Delete")
|
||||
title: "Delete session?",
|
||||
message: "Deletes the “\(key)” entry and archives its transcript.",
|
||||
action: "Delete")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
|
||||
@@ -11,10 +11,39 @@ actor MacNodeBridgePairingClient {
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await AsyncTimeout.withTimeout(
|
||||
@@ -164,6 +193,18 @@ actor MacNodeBridgePairingClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(
|
||||
_ connection: NWConnection,
|
||||
queue: DispatchQueue) async throws
|
||||
|
||||
@@ -36,23 +36,44 @@ actor MacNodeBridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.disconnectHandler = onDisconnected
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -77,15 +98,15 @@ actor MacNodeBridgeSession {
|
||||
})
|
||||
|
||||
guard let line = try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
self.logger.error("node bridge hello failed (unexpected response)")
|
||||
await self.disconnect()
|
||||
@@ -262,6 +283,25 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
return params
|
||||
}
|
||||
|
||||
private func failRPC(id: String, error: Error) async {
|
||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||
cont.resume(throwing: error)
|
||||
@@ -420,7 +460,7 @@ actor MacNodeBridgeSession {
|
||||
do {
|
||||
try await self.send(response)
|
||||
} catch {
|
||||
await self.logInvokeSendFailure(error)
|
||||
self.logInvokeSendFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift
Normal file
74
apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
struct MacNodeBridgeTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum MacNodeBridgeTLSStore {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let keyPrefix = "mac.node.bridge.tls."
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
|
||||
guard let params else { return nil }
|
||||
let options = NWProtocolTLS.Options()
|
||||
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
|
||||
let allowTOFU = params.allowTOFU
|
||||
let storeKey = params.storeKey
|
||||
|
||||
sec_protocol_options_set_verify_block(
|
||||
options.securityProtocolOptions,
|
||||
{ _, trust, complete in
|
||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
{
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let fingerprint = sha256Hex(data)
|
||||
if let expected {
|
||||
complete(fingerprint == expected)
|
||||
return
|
||||
}
|
||||
if allowTOFU {
|
||||
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
||||
complete(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||
complete(ok)
|
||||
},
|
||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter(\.isHexDigit)
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
private struct BridgeTarget {
|
||||
let endpoint: NWEndpoint
|
||||
let stableID: String
|
||||
let tls: MacNodeBridgeTLSParams?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
@@ -37,6 +43,7 @@ final class MacNodeModeCoordinator {
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastSystemRunPolicy: SystemRunPolicy?
|
||||
let defaults = UserDefaults.standard
|
||||
while !Task.isCancelled {
|
||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||
@@ -53,7 +60,16 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
let systemRunPolicy = SystemRunPolicy.load()
|
||||
if lastSystemRunPolicy == nil {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
continue
|
||||
@@ -63,10 +79,11 @@ final class MacNodeModeCoordinator {
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
self.logger.info(
|
||||
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
||||
try await self.session.connect(
|
||||
endpoint: endpoint,
|
||||
endpoint: target.endpoint,
|
||||
hello: hello,
|
||||
tls: target.tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
if let mainSessionKey {
|
||||
@@ -86,7 +103,7 @@ final class MacNodeModeCoordinator {
|
||||
return await self.runtime.handleInvoke(req)
|
||||
})
|
||||
} catch {
|
||||
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||
if await self.tryPair(target: target, error: error) {
|
||||
continue
|
||||
}
|
||||
self.logger.error(
|
||||
@@ -143,10 +160,14 @@ final class MacNodeModeCoordinator {
|
||||
ClawdbotCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
]
|
||||
|
||||
if SystemRunPolicy.load() != .never {
|
||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||
}
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||
@@ -160,7 +181,7 @@ final class MacNodeModeCoordinator {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
||||
let text = error.localizedDescription.uppercased()
|
||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||
|
||||
@@ -170,9 +191,10 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let hello = await self.makeHello()
|
||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
endpoint: target.endpoint,
|
||||
hello: hello,
|
||||
silent: shouldSilent,
|
||||
tls: target.tls,
|
||||
onStatus: { [weak self] status in
|
||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||
})
|
||||
@@ -190,7 +212,7 @@ final class MacNodeModeCoordinator {
|
||||
"mac-\(InstanceIdentity.instanceId)"
|
||||
}
|
||||
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
guard let port = Self.loopbackBridgePort(),
|
||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||
else {
|
||||
@@ -198,7 +220,10 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||
return reachable ? endpoint : nil
|
||||
guard reachable else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
static func loopbackBridgePort() -> UInt16? {
|
||||
@@ -291,7 +316,7 @@ final class MacNodeModeCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
do {
|
||||
@@ -303,7 +328,10 @@ final class MacNodeModeCoordinator {
|
||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||
self.logger.info(
|
||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||
@@ -312,9 +340,23 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
let preferredLocalPort = Self.loopbackBridgePort()
|
||||
if let preferredLocalPort {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
} else {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=none " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
}
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
preferredLocalPort: preferredLocalPort,
|
||||
allowRemoteUrlOverride: false,
|
||||
allowRandomLocalPort: true)
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
{
|
||||
@@ -322,7 +364,10 @@ final class MacNodeModeCoordinator {
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -333,8 +378,8 @@ final class MacNodeModeCoordinator {
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return endpoint
|
||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return target
|
||||
}
|
||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
@@ -354,14 +399,14 @@ final class MacNodeModeCoordinator {
|
||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
final class DiscoveryState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var resolved = false
|
||||
var browsers: [NWBrowser] = []
|
||||
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
||||
|
||||
func finish(_ endpoint: NWEndpoint?) {
|
||||
func finish(_ target: BridgeTarget?) {
|
||||
self.lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if self.resolved { return }
|
||||
@@ -369,7 +414,7 @@ final class MacNodeModeCoordinator {
|
||||
for browser in self.browsers {
|
||||
browser.cancel()
|
||||
}
|
||||
self.continuation?.resume(returning: endpoint)
|
||||
self.continuation?.resume(returning: target)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
@@ -389,18 +434,18 @@ final class MacNodeModeCoordinator {
|
||||
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
||||
if let preferred,
|
||||
let match = results.first(where: {
|
||||
if case .service = $0.endpoint {
|
||||
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
||||
}
|
||||
return false
|
||||
if case .service = $0.endpoint {
|
||||
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
||||
}
|
||||
return false
|
||||
})
|
||||
{
|
||||
state.finish(match.endpoint)
|
||||
state.finish(Self.targetFromResult(match))
|
||||
return
|
||||
}
|
||||
|
||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||
state.finish(result.endpoint)
|
||||
state.finish(Self.targetFromResult(result))
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { browserState in
|
||||
@@ -418,6 +463,72 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
||||
let endpoint = result.endpoint
|
||||
guard case .service = endpoint else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
|
||||
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
|
||||
let tlsParams = Self.resolveDiscoveredTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: tlsEnabled,
|
||||
tlsFingerprintSha256: tlsFingerprint)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
private nonisolated static func resolveDiscoveredTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
||||
{
|
||||
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if tlsEnabled || tlsFingerprintSha256 != nil {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? nil : raw
|
||||
}
|
||||
|
||||
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
enum MacNodeTokenStore {
|
||||
|
||||
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
case ClawdbotSystemCommand.run.rawValue:
|
||||
return try await self.handleSystemRun(req)
|
||||
case ClawdbotSystemCommand.which.rawValue:
|
||||
return try await self.handleSystemWhich(req)
|
||||
case ClawdbotSystemCommand.notify.rawValue:
|
||||
return try await self.handleSystemNotify(req)
|
||||
default:
|
||||
@@ -181,10 +183,10 @@ actor MacNodeRuntime {
|
||||
var height: Int
|
||||
}
|
||||
let payload = try Self.encodePayload(SnapPayload(
|
||||
format: (params.format ?? .jpg).rawValue,
|
||||
base64: res.data.base64EncodedString(),
|
||||
width: Int(res.size.width),
|
||||
height: Int(res.size.height)))
|
||||
format: (params.format ?? .jpg).rawValue,
|
||||
base64: res.data.base64EncodedString(),
|
||||
width: Int(res.size.width),
|
||||
height: Int(res.size.height)))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
case ClawdbotCameraCommand.clip.rawValue:
|
||||
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
|
||||
@@ -204,10 +206,10 @@ actor MacNodeRuntime {
|
||||
var hasAudio: Bool
|
||||
}
|
||||
let payload = try Self.encodePayload(ClipPayload(
|
||||
format: (params.format ?? .mp4).rawValue,
|
||||
base64: data.base64EncodedString(),
|
||||
durationMs: res.durationMs,
|
||||
hasAudio: res.hasAudio))
|
||||
format: (params.format ?? .mp4).rawValue,
|
||||
base64: data.base64EncodedString(),
|
||||
durationMs: res.durationMs,
|
||||
hasAudio: res.hasAudio))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
case ClawdbotCameraCommand.list.rawValue:
|
||||
let devices = await self.cameraCapture.listDevices()
|
||||
@@ -312,12 +314,12 @@ actor MacNodeRuntime {
|
||||
var hasAudio: Bool
|
||||
}
|
||||
let payload = try Self.encodePayload(ScreenPayload(
|
||||
format: "mp4",
|
||||
base64: data.base64EncodedString(),
|
||||
durationMs: params.durationMs,
|
||||
fps: params.fps,
|
||||
screenIndex: params.screenIndex,
|
||||
hasAudio: res.hasAudio))
|
||||
format: "mp4",
|
||||
base64: data.base64EncodedString(),
|
||||
durationMs: params.durationMs,
|
||||
fps: params.fps,
|
||||
screenIndex: params.screenIndex,
|
||||
hasAudio: res.hasAudio))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
@@ -426,6 +428,37 @@ actor MacNodeRuntime {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
|
||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||
switch Self.systemRunPolicy() {
|
||||
case .never:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
||||
case .always:
|
||||
break
|
||||
case .ask:
|
||||
if !wasAllowlisted {
|
||||
let services = await self.mainActorServices()
|
||||
let decision = await services.confirmSystemRun(
|
||||
command: SystemRunAllowlist.displayString(for: command),
|
||||
cwd: params.cwd)
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
break
|
||||
case .allowAlways:
|
||||
SystemRunAllowlist.add(command)
|
||||
case .deny:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
@@ -441,7 +474,7 @@ actor MacNodeRuntime {
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
@@ -454,12 +487,39 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
let payload = try Self.encodePayload(RunPayload(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage))
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
|
||||
let bins = params.bins
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !bins.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
||||
}
|
||||
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
var matches: [String] = []
|
||||
var paths: [String: String] = [:]
|
||||
for bin in bins {
|
||||
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
||||
matches.append(bin)
|
||||
paths[bin] = path
|
||||
}
|
||||
}
|
||||
|
||||
struct WhichPayload: Encodable {
|
||||
let bins: [String]
|
||||
let paths: [String: String]
|
||||
}
|
||||
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
@@ -529,6 +589,39 @@ actor MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
||||
SystemRunPolicy.load()
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]
|
||||
|
||||
private static let blockedEnvPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
]
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||
guard let overrides else { return nil }
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if self.blockedEnvKeys.contains(upper) { continue }
|
||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||
@@ -576,8 +669,8 @@ actor MacNodeRuntime {
|
||||
case .jpeg:
|
||||
let clamped = min(1.0, max(0.05, quality))
|
||||
guard let data = rep.representation(
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: clamped])
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: clamped])
|
||||
else {
|
||||
throw NSError(domain: "Canvas", code: 24, userInfo: [
|
||||
NSLocalizedDescriptionKey: "jpeg encode failed",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
enum SystemRunDecision: Sendable {
|
||||
case allowOnce
|
||||
case allowAlways
|
||||
case deny
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -57,4 +66,31 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
details += "\n\nThis runs on this Mac via node mode."
|
||||
alert.informativeText = details
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .allowOnce
|
||||
case .alertSecondButtonReturn:
|
||||
return .allowAlways
|
||||
default:
|
||||
return .deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
guard settings.authorizationStatus == .authorized ||
|
||||
settings.authorizationStatus == .provisional
|
||||
settings.authorizationStatus == .provisional
|
||||
else {
|
||||
return
|
||||
}
|
||||
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
|
||||
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||
guard let gateway else { return nil }
|
||||
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||
guard let host, !host.isEmpty else { return nil }
|
||||
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
||||
return SSHTarget(host: host, port: port)
|
||||
|
||||
@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
|
||||
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
|
||||
|
||||
let snapshots = InMemorySnapshotManager(options: .init(
|
||||
snapshotValidityWindow: 600,
|
||||
maxSnapshots: 50,
|
||||
deleteArtifactsOnCleanup: false))
|
||||
snapshotValidityWindow: 600,
|
||||
maxSnapshots: 50,
|
||||
deleteArtifactsOnCleanup: false))
|
||||
let applications = ApplicationService(feedbackClient: feedbackClient)
|
||||
|
||||
let screenCapture = ScreenCaptureService(loggingService: logging)
|
||||
|
||||
@@ -158,10 +158,10 @@ actor PortGuardian {
|
||||
mode: mode,
|
||||
listeners: listeners)
|
||||
reports.append(Self.buildReport(
|
||||
port: port,
|
||||
listeners: listeners,
|
||||
mode: mode,
|
||||
tunnelHealthy: tunnelHealthy))
|
||||
port: port,
|
||||
listeners: listeners,
|
||||
mode: mode,
|
||||
tunnelHealthy: tunnelHealthy))
|
||||
}
|
||||
|
||||
return reports
|
||||
@@ -351,10 +351,11 @@ actor PortGuardian {
|
||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||
return false
|
||||
case .local:
|
||||
if !cmd.contains("clawdbot") { return false }
|
||||
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
|
||||
if full.contains("gateway-daemon") { return true }
|
||||
// If args are unavailable, treat a clawdbot listener as expected.
|
||||
return full == cmd
|
||||
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||
return false
|
||||
case .unconfigured:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ final class RemotePortTunnel {
|
||||
return
|
||||
}
|
||||
guard let line = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!line.isEmpty
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!line.isEmpty
|
||||
else { return }
|
||||
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ struct RuntimeResolution {
|
||||
enum RuntimeResolutionError: Error {
|
||||
case notFound(searchPaths: [String])
|
||||
case unsupported(
|
||||
kind: RuntimeKind,
|
||||
found: RuntimeVersion,
|
||||
required: RuntimeVersion,
|
||||
path: String,
|
||||
searchPaths: [String])
|
||||
kind: RuntimeKind,
|
||||
found: RuntimeVersion,
|
||||
required: RuntimeVersion,
|
||||
path: String,
|
||||
searchPaths: [String])
|
||||
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
||||
}
|
||||
|
||||
@@ -65,21 +65,21 @@ enum RuntimeLocator {
|
||||
}
|
||||
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
||||
return .failure(.versionParse(
|
||||
kind: runtime,
|
||||
raw: "(unreadable)",
|
||||
path: binary,
|
||||
searchPaths: searchPaths))
|
||||
kind: runtime,
|
||||
raw: "(unreadable)",
|
||||
path: binary,
|
||||
searchPaths: searchPaths))
|
||||
}
|
||||
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
||||
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
||||
}
|
||||
guard parsed >= self.minNode else {
|
||||
return .failure(.unsupported(
|
||||
kind: runtime,
|
||||
found: parsed,
|
||||
required: self.minNode,
|
||||
path: binary,
|
||||
searchPaths: searchPaths))
|
||||
kind: runtime,
|
||||
found: parsed,
|
||||
required: self.minNode,
|
||||
path: binary,
|
||||
searchPaths: searchPaths))
|
||||
}
|
||||
|
||||
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
||||
|
||||
@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
|
||||
if let err = self.writer.error {
|
||||
cont
|
||||
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||
.writeFailed(err.localizedDescription))
|
||||
.writeFailed(err.localizedDescription))
|
||||
} else if self.writer.status != .completed {
|
||||
cont
|
||||
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||
.writeFailed("Failed to finalize video"))
|
||||
.writeFailed("Failed to finalize video"))
|
||||
} else {
|
||||
cont.resume()
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
|
||||
var map: [String: URL] = [:]
|
||||
for root in Self.searchRoots {
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles])
|
||||
at: root,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles])
|
||||
else { continue }
|
||||
|
||||
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
|
||||
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
|
||||
static func sound(from bookmark: Data) -> NSSound? {
|
||||
var stale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: bookmark,
|
||||
options: [.withoutUI, .withSecurityScope],
|
||||
bookmarkDataIsStale: &stale)
|
||||
resolvingBookmarkData: bookmark,
|
||||
options: [.withoutUI, .withSecurityScope],
|
||||
bookmarkDataIsStale: &stale)
|
||||
else { return nil }
|
||||
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
|
||||
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
89
apps/macos/Sources/Clawdbot/SystemRunPolicy.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
case never
|
||||
case ask
|
||||
case always
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .never:
|
||||
"Never"
|
||||
case .ask:
|
||||
"Always Ask"
|
||||
case .always:
|
||||
"Always Allow"
|
||||
}
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||
return policy
|
||||
}
|
||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
||||
let policy = SystemRunPolicy(rawValue: raw)
|
||||
{
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
let fallback: SystemRunPolicy = .ask
|
||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunAllowlist {
|
||||
static func key(for argv: [String]) -> String {
|
||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let data = try? JSONEncoder().encode(trimmed),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return json
|
||||
}
|
||||
return trimmed.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||
return Set(allowlist)
|
||||
}
|
||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||
return Set(legacy)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||
let key = key(for: argv)
|
||||
return self.load(from: defaults).contains(key)
|
||||
}
|
||||
|
||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||
let key = key(for: argv)
|
||||
guard !key.isEmpty else { return }
|
||||
var allowlist = self.load(from: defaults)
|
||||
if allowlist.insert(key).inserted {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,9 +354,9 @@ actor TalkModeRuntime {
|
||||
"session=\(sessionKey, privacy: .public)")
|
||||
|
||||
guard let assistantText = await self.waitForAssistantText(
|
||||
sessionKey: sessionKey,
|
||||
since: startedAt,
|
||||
timeoutSeconds: 45)
|
||||
sessionKey: sessionKey,
|
||||
since: startedAt,
|
||||
timeoutSeconds: 45)
|
||||
else {
|
||||
self.logger.warning("talk assistant text missing after timeout")
|
||||
await self.startListening()
|
||||
|
||||
@@ -48,12 +48,12 @@ enum VoiceWakeForwarder {
|
||||
let payload = Self.prefixedTranscript(transcript)
|
||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: payload,
|
||||
sessionKey: options.sessionKey,
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
channel: options.channel))
|
||||
message: payload,
|
||||
sessionKey: options.sessionKey,
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
channel: options.channel))
|
||||
|
||||
if result.ok {
|
||||
self.logger.info("voice wake forward ok")
|
||||
|
||||
@@ -493,10 +493,10 @@ actor VoiceWakeRuntime {
|
||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||
{
|
||||
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: Self.trimmedAfterTrigger)
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: Self.trimmedAfterTrigger)
|
||||
else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||
}
|
||||
@@ -519,9 +519,9 @@ actor VoiceWakeRuntime {
|
||||
guard let lastSeenAt, let lastText else { return }
|
||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||
guard let match = self.textOnlyFallbackMatch(
|
||||
transcript: lastText,
|
||||
triggers: triggers,
|
||||
config: gateConfig)
|
||||
transcript: lastText,
|
||||
triggers: triggers,
|
||||
config: gateConfig)
|
||||
else { return }
|
||||
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
||||
return
|
||||
|
||||
@@ -155,7 +155,7 @@ struct VoiceWakeSettings: View {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.disabled(self.state.swabbleTriggerWords
|
||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
||||
}
|
||||
@@ -440,21 +440,21 @@ struct VoiceWakeSettings: View {
|
||||
{ idx, localeID in
|
||||
HStack(spacing: 8) {
|
||||
Picker("Extra \(idx + 1)", selection: Binding(
|
||||
get: { localeID },
|
||||
set: { newValue in
|
||||
guard self.state
|
||||
.voiceWakeAdditionalLocaleIDs.indices
|
||||
.contains(idx) else { return }
|
||||
self.state
|
||||
.voiceWakeAdditionalLocaleIDs[idx] =
|
||||
newValue
|
||||
})) {
|
||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||
get: { localeID },
|
||||
set: { newValue in
|
||||
guard self.state
|
||||
.voiceWakeAdditionalLocaleIDs.indices
|
||||
.contains(idx) else { return }
|
||||
self.state
|
||||
.voiceWakeAdditionalLocaleIDs[idx] =
|
||||
newValue
|
||||
})) {
|
||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
|
||||
@@ -360,10 +360,10 @@ final class VoiceWakeTester {
|
||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||
{
|
||||
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||
transcript: transcript,
|
||||
triggers: triggers,
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||
else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||
}
|
||||
@@ -408,9 +408,9 @@ final class VoiceWakeTester {
|
||||
guard let lastSeenAt, let lastText else { return }
|
||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||
guard let match = self.textOnlyFallbackMatch(
|
||||
transcript: lastText,
|
||||
triggers: triggers,
|
||||
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
||||
transcript: lastText,
|
||||
triggers: triggers,
|
||||
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
||||
self.holdingAfterDetect = true
|
||||
self.detectedText = match.command
|
||||
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
||||
|
||||
@@ -92,8 +92,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
||||
switch push {
|
||||
case let .snapshot(hello):
|
||||
let ok = (try? JSONDecoder().decode(
|
||||
ClawdbotGatewayHealthOK.self,
|
||||
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
||||
ClawdbotGatewayHealthOK.self,
|
||||
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
||||
return .health(ok: ok)
|
||||
|
||||
case let .event(evt):
|
||||
@@ -101,16 +101,16 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
||||
case "health":
|
||||
guard let payload = evt.payload else { return nil }
|
||||
let ok = (try? JSONDecoder().decode(
|
||||
ClawdbotGatewayHealthOK.self,
|
||||
from: JSONEncoder().encode(payload)))?.ok ?? true
|
||||
ClawdbotGatewayHealthOK.self,
|
||||
from: JSONEncoder().encode(payload)))?.ok ?? true
|
||||
return .health(ok: ok)
|
||||
case "tick":
|
||||
return .tick
|
||||
case "chat":
|
||||
guard let payload = evt.payload else { return nil }
|
||||
guard let chat = try? JSONDecoder().decode(
|
||||
ClawdbotChatEventPayload.self,
|
||||
from: JSONEncoder().encode(payload))
|
||||
ClawdbotChatEventPayload.self,
|
||||
from: JSONEncoder().encode(payload))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -118,8 +118,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
||||
case "agent":
|
||||
guard let payload = evt.payload else { return nil }
|
||||
guard let agent = try? JSONDecoder().decode(
|
||||
ClawdbotAgentEventPayload.self,
|
||||
from: JSONEncoder().encode(payload))
|
||||
ClawdbotAgentEventPayload.self,
|
||||
from: JSONEncoder().encode(payload))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -157,9 +157,9 @@ final class WebChatSwiftUIWindowController {
|
||||
let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||
self.hosting = NSHostingController(rootView: ClawdbotChatView(
|
||||
viewModel: vm,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: accent))
|
||||
viewModel: vm,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: accent))
|
||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||
}
|
||||
|
||||
@@ -41,12 +41,12 @@ enum WideAreaGatewayDiscovery {
|
||||
}
|
||||
|
||||
guard let ips = collectTailnetIPv4s(
|
||||
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
||||
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
||||
var candidates = Array(ips.prefix(self.maxCandidates))
|
||||
guard let nameserver = findNameserver(
|
||||
candidates: &candidates,
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
candidates: &candidates,
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
@@ -55,9 +55,9 @@ enum WideAreaGatewayDiscovery {
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
!ptrLines.isEmpty
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
!ptrLines.isEmpty
|
||||
else {
|
||||
return []
|
||||
}
|
||||
@@ -74,8 +74,8 @@ enum WideAreaGatewayDiscovery {
|
||||
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
||||
|
||||
guard let srv = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||
min(defaultTimeoutSeconds, remaining()))
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||
min(defaultTimeoutSeconds, remaining()))
|
||||
else { continue }
|
||||
guard let (host, port) = parseSrv(srv) else { continue }
|
||||
|
||||
@@ -198,7 +198,7 @@ enum WideAreaGatewayDiscovery {
|
||||
if let stdout = dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, budget)),
|
||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
||||
{
|
||||
state.lock.lock()
|
||||
if state.found == nil {
|
||||
|
||||
@@ -107,18 +107,18 @@ public enum CanvasA2UICommand: String, Codable, Sendable {
|
||||
|
||||
public enum Request: Sendable {
|
||||
case notify(
|
||||
title: String,
|
||||
body: String,
|
||||
sound: String?,
|
||||
priority: NotificationPriority?,
|
||||
delivery: NotificationDelivery?)
|
||||
title: String,
|
||||
body: String,
|
||||
sound: String?,
|
||||
priority: NotificationPriority?,
|
||||
delivery: NotificationDelivery?)
|
||||
case ensurePermissions([Capability], interactive: Bool)
|
||||
case runShell(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutSec: Double?,
|
||||
needsScreenRecording: Bool)
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutSec: Double?,
|
||||
needsScreenRecording: Bool)
|
||||
case status
|
||||
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
|
||||
case rpcStatus
|
||||
@@ -410,6 +410,6 @@ extension Request: Codable {
|
||||
// Shared transport settings
|
||||
public let controlSocketPath =
|
||||
FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
|
||||
@@ -1679,6 +1679,27 @@ public struct ChatAbortParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatInjectParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let message: String
|
||||
public let label: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
message: String,
|
||||
label: String?
|
||||
) {
|
||||
self.sessionkey = sessionkey
|
||||
self.message = message
|
||||
self.label = label
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case message
|
||||
case label
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
var view = CronJobEditor(
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.name = "One-shot"
|
||||
view.sessionTarget = .main
|
||||
view.payloadKind = .systemEvent
|
||||
view.systemEventText = "hello"
|
||||
view.scheduleKind = .at
|
||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
view.deleteAfterRun = true
|
||||
|
||||
let payload = try view.buildPayload()
|
||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||
var root: [String: Any] = [:]
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
||||
let raw = root["deleteAfterRun"] as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ struct CronModelsTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 15,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: false)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
|
||||
@@ -7,6 +7,8 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
@@ -31,6 +33,8 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
@@ -48,6 +52,8 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: nil,
|
||||
|
||||
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
|
||||
host: "gateway",
|
||||
ip: "10.0.0.4",
|
||||
version: "3.0.0",
|
||||
platform: "iOS 17",
|
||||
platform: "iOS 18",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
|
||||
@@ -112,20 +112,6 @@ struct LowCoverageHelperTests {
|
||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||
}
|
||||
|
||||
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||
await TestIsolation.withEnvValues(
|
||||
[
|
||||
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||
]) {
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||
let output = """
|
||||
p123
|
||||
|
||||
@@ -11,7 +11,8 @@ import Testing
|
||||
stateversion: StateVersion(presence: 1, health: 1),
|
||||
uptimems: 123,
|
||||
configpath: nil,
|
||||
statedir: nil)
|
||||
statedir: nil,
|
||||
sessiondefaults: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
|
||||
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemWhichParams(bins: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
|
||||
{
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
.allowOnce
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
|
||||
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
|
||||
}
|
||||
|
||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
#expect(!order.contains(7))
|
||||
#expect(order.contains(3))
|
||||
}
|
||||
|
||||
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
provider: "sms",
|
||||
channel: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -8,7 +8,7 @@ import Testing
|
||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||
|
||||
_ = try await withTimeout(seconds: 2.0) {
|
||||
_ = try await withTimeout(seconds: 4.0) {
|
||||
await TalkAudioPlayer.shared.play(data: wav)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import Testing
|
||||
await Task.yield()
|
||||
_ = await TalkAudioPlayer.shared.play(data: wav)
|
||||
|
||||
_ = try await withTimeout(seconds: 2.0) {
|
||||
_ = try await withTimeout(seconds: 4.0) {
|
||||
await first.value
|
||||
}
|
||||
#expect(true)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user