Compare commits
194
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ccc1e542 | ||
|
|
62324eed0b | ||
|
|
0f6f7059d9 | ||
|
|
67f63ecd7e | ||
|
|
1420d113d8 | ||
|
|
5b4651d9ed | ||
|
|
5f22b68268 | ||
|
|
34590d2144 | ||
|
|
0c93b9b7bb | ||
|
|
b659db0a5b | ||
|
|
9fd9f4c896 | ||
|
|
005b831023 | ||
|
|
8013c4717c | ||
|
|
14e6b21b50 | ||
|
|
62354dff9c | ||
|
|
ccb30665f7 | ||
|
|
0fb2777c6d | ||
|
|
568b8ee96c | ||
|
|
fc60699f03 | ||
|
|
c1da78a271 | ||
|
|
0674f1fa3c | ||
|
|
3a0fd6be3c | ||
|
|
8b1bec11d0 | ||
|
|
f73dbdbaea | ||
|
|
05f49d2846 | ||
|
|
1d83389776 | ||
|
|
e0e8f11f70 | ||
|
|
36d88f6079 | ||
|
|
2c070952e1 | ||
|
|
fc45148155 | ||
|
|
215c395fc2 | ||
|
|
b56b67cdbd | ||
|
|
a760db9921 | ||
|
|
8eb80ee40a | ||
|
|
f9e3b129ed | ||
|
|
e5050abe2a | ||
|
|
4f0771f67b | ||
|
|
075ff675ac | ||
|
|
c7ea47e886 | ||
|
|
b543339373 | ||
|
|
22c7f659f6 | ||
|
|
79a44d0da4 | ||
|
|
d593a809f0 | ||
|
|
22add31e91 | ||
|
|
b44d740720 | ||
|
|
4d590f9254 | ||
|
|
a5aa48beea | ||
|
|
1bf3861ca4 | ||
|
|
ff9d069a33 | ||
|
|
f8052be369 | ||
|
|
a08438ae97 | ||
|
|
fe00d6aacf | ||
|
|
984692cda2 | ||
|
|
4c12c4fc04 | ||
|
|
794bab45ff | ||
|
|
16e5fa1db9 | ||
|
|
125be3e111 | ||
|
|
b60a53e10d | ||
|
|
9de762faa2 | ||
|
|
5aed38eebc | ||
|
|
e63e483c38 | ||
|
|
277e43e32c | ||
|
|
852aa16ca0 | ||
|
|
82b7153ac1 | ||
|
|
7d2e510087 | ||
|
|
9ca4c10e59 | ||
|
|
a31a79396b | ||
|
|
acc3eb11d0 | ||
|
|
9d9fff2991 | ||
|
|
030ed5d592 | ||
|
|
f6d359932a | ||
|
|
3200b51160 | ||
|
|
4b11ebb30e | ||
|
|
40345642fa | ||
|
|
e932772230 | ||
|
|
63d466fe5e | ||
|
|
c2fada7062 | ||
|
|
d9c29f5ce5 | ||
|
|
f5d5ef6857 | ||
|
|
361a17415f | ||
|
|
fb393c3c51 | ||
|
|
e0158c5d5d | ||
|
|
be12b0771c | ||
|
|
1309fc1f48 | ||
|
|
4fdecfb845 | ||
|
|
31c6f178f3 | ||
|
|
1e2ab8bf1e | ||
|
|
35a1d81518 | ||
|
|
1c4297d8b5 | ||
|
|
e3638a9a9e | ||
|
|
1f8558771a | ||
|
|
2e231d09ec | ||
|
|
727c07bd88 | ||
|
|
c32ad19377 | ||
|
|
ef40ab2933 | ||
|
|
e71fa4a145 | ||
|
|
a7c0887f94 | ||
|
|
53218b91c6 | ||
|
|
2d4de656d2 | ||
|
|
b0f44acf9e | ||
|
|
a828e60067 | ||
|
|
96df70fccf | ||
|
|
0e49dca53c | ||
|
|
8ec4af4641 | ||
|
|
2f6d9417bd | ||
|
|
534a012a4e | ||
|
|
7a3fa9ce03 | ||
|
|
8a67d29748 | ||
|
|
408f4f2dac | ||
|
|
3df2dc0b15 | ||
|
|
5304a8c2d1 | ||
|
|
1569d29b2d | ||
|
|
50c8e74230 | ||
|
|
1045b032a2 | ||
|
|
a813343aa7 | ||
|
|
5a08471dcd | ||
|
|
252dfbcd40 | ||
|
|
75588fe732 | ||
|
|
9bbdeb3d52 | ||
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
30c945fe92 | ||
|
|
dfd511c310 | ||
|
|
1657525201 | ||
|
|
3e4b0d0505 | ||
|
|
003c6c9ae1 | ||
|
|
41fbb4d8b0 | ||
|
|
eecb340f64 | ||
|
|
d029eaa0bb | ||
|
|
9c7dcc1ed7 | ||
|
|
be37b39782 | ||
|
|
49c35c752c | ||
|
|
25d8043b9d | ||
|
|
f9f4a953fc | ||
|
|
34c3fbc66c | ||
|
|
a9f21b3d3a | ||
|
|
ed5c5629f6 | ||
|
|
868952f958 | ||
|
|
9b9836be71 | ||
|
|
22cd839cb2 | ||
|
|
dc3ac9fa28 | ||
|
|
c874fa9712 | ||
|
|
6b784a9771 | ||
|
|
f8e673cdbc | ||
|
|
ad360b4d18 | ||
|
|
69ba2765de | ||
|
|
31e8ecca10 | ||
|
|
4ca38286d8 | ||
|
|
fbf1c3ca3c | ||
|
|
1a4313c2aa | ||
|
|
a6deb0d9d5 | ||
|
|
b6ea5895b6 | ||
|
|
d66bc65ca6 | ||
|
|
89f85ddeab | ||
|
|
bbb71c9198 | ||
|
|
ae6792522d | ||
|
|
e637bbdfb5 | ||
|
|
869ef0c5ba | ||
|
|
1002c74d9c | ||
|
|
61e60f3b84 | ||
|
|
13b931c006 | ||
|
|
ab49fe0e92 | ||
|
|
d0bc08a934 | ||
|
|
64d21f5ea8 | ||
|
|
0a95d8a840 | ||
|
|
56f3a2de25 | ||
|
|
d8b463d0b3 | ||
|
|
eef3df9fa5 | ||
|
|
837eea4ebd | ||
|
|
55622bac06 | ||
|
|
f172ccfcf6 | ||
|
|
a2a6893566 | ||
|
|
616ee3075c | ||
|
|
cddf198321 |
No files matched your search
@@ -73,6 +73,7 @@
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
@@ -117,6 +118,7 @@
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- 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.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
|
||||
+126
-12
@@ -1,41 +1,136 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16 (unreleased)
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
|
||||
### Fixes
|
||||
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
|
||||
|
||||
## 2026.1.17-4
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
- Sessions: persist origin metadata across connectors for generic session explainers.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
### Highlights
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
||||
- Messages: refresh live directory cache results when resolving targets.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
@@ -44,31 +139,50 @@
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Tools: include provider/session context in elevated exec denial errors.
|
||||
- Tools: normalize exec tool alias naming in tool error logs.
|
||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
||||
- Logging: prefix nested agent output with session/run/channel context.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Sessions: preserve overrides on `/new` reset.
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
- Messages: honor message tool channel when deduping sends.
|
||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
|
||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
||||
- Security: redact sensitive text in gateway WS logs.
|
||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- WhatsApp: include `linked` field in `describeAccount`.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: hide the image tool when the primary model already supports images.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
|
||||
- iMessage: avoid RPC restart loops.
|
||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
|
||||
- CLI: auto-update global installs when installed via a package manager.
|
||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||
@@ -76,6 +190,7 @@
|
||||
- Models: align ZAI thinking toggles.
|
||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
@@ -116,7 +231,6 @@
|
||||
- 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.
|
||||
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
|
||||
- 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.
|
||||
|
||||
@@ -478,20 +478,21 @@ Thanks to all clawtributors:
|
||||
<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/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/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/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/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/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/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/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/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/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/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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/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=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||
<a href="https://github.com/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/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/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
||||
<a href="https://github.com/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/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/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/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/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/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/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/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/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/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/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=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/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/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
+16
-16
@@ -2,6 +2,22 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>6273</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
@@ -255,21 +271,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.12-2</title>
|
||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5534</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
|
||||
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(schema, path: path)
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
}
|
||||
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = store.configValue(at: path)
|
||||
let storedValue = self.store.configValue(at: path)
|
||||
let value = storedValue ?? schema.explicitDefault
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
|
||||
if nonNull.count == 1, let only = nonNull.first {
|
||||
return self.renderNode(only, path: path)
|
||||
}
|
||||
let literals = nonNull.compactMap { $0.literalValue }
|
||||
let literals = nonNull.compactMap(\.literalValue)
|
||||
if !literals.isEmpty, literals.count == nonNull.count {
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
|
||||
Picker(
|
||||
"",
|
||||
selection: self.enumBinding(
|
||||
path,
|
||||
options: literals,
|
||||
defaultValue: schema.explicitDefault))
|
||||
{
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(literals.indices, id: \ .self) { index in
|
||||
Text(String(describing: literals[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
|
||||
if schema.allowsAdditionalProperties {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
case "array":
|
||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||
case "boolean":
|
||||
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
|
||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
}
|
||||
.help(help ?? "")
|
||||
)
|
||||
.help(help ?? ""))
|
||||
case "number", "integer":
|
||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||
case "string":
|
||||
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
|
||||
Text("Unsupported field type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue
|
||||
)
|
||||
)
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = items
|
||||
next.remove(at: index)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
|
||||
} else {
|
||||
next.append("")
|
||||
}
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = dict
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
|
||||
key = "new-\(index)"
|
||||
}
|
||||
next[key] = additionalSchema.defaultValue
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
})
|
||||
}
|
||||
|
||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
||||
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
|
||||
return defaultValue ?? false
|
||||
},
|
||||
set: { newValue in
|
||||
store.updateConfigValue(path: path, value: newValue)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: newValue)
|
||||
})
|
||||
}
|
||||
|
||||
private func numberBinding(
|
||||
_ path: ConfigPath,
|
||||
isInteger: Bool,
|
||||
defaultValue: Double?
|
||||
) -> Binding<String> {
|
||||
defaultValue: Double?) -> Binding<String>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
} else if let value = Double(trimmed) {
|
||||
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private func enumBinding(
|
||||
_ path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?
|
||||
) -> Binding<Int> {
|
||||
defaultValue: Any?) -> Binding<Int>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
let value = store.configValue(at: path) ?? defaultValue
|
||||
let value = self.store.configValue(at: path) ?? defaultValue
|
||||
guard let value else { return -1 }
|
||||
return options.firstIndex { option in
|
||||
String(describing: option) == String(describing: value)
|
||||
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { index in
|
||||
guard index >= 0, index < options.count else {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
return
|
||||
}
|
||||
store.updateConfigValue(path: path, value: options[index])
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: options[index])
|
||||
})
|
||||
}
|
||||
|
||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard trimmed != key else { return }
|
||||
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
guard current[trimmed] == nil else { return }
|
||||
var next = current
|
||||
next[trimmed] = current[key]
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
|
||||
let channelId: String
|
||||
|
||||
var body: some View {
|
||||
if store.configSchemaLoading {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -434,25 +434,25 @@ extension ChannelsSettings {
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "WhatsApp Web"
|
||||
case "telegram": return "Telegram Bot"
|
||||
case "discord": return "Discord Bot"
|
||||
case "slack": return "Slack Bot"
|
||||
case "signal": return "Signal REST"
|
||||
case "imessage": return "iMessage"
|
||||
default: return self.resolveChannelTitle(id)
|
||||
case "whatsapp": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "message"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "signal": return "antenna.radiowaves.left.and.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "message"
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ extension ChannelsStore {
|
||||
return value
|
||||
}
|
||||
guard path.count >= 2 else { return nil }
|
||||
if case .key("channels") = path[0], case .key(_) = path[1] {
|
||||
if case .key("channels") = path[0], case .key = path[1] {
|
||||
let fallbackPath = Array(path.dropFirst())
|
||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||
}
|
||||
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
var current: Any? = root
|
||||
for segment in path {
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||
current = array[index]
|
||||
}
|
||||
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
guard let segment = path.first else { return }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
var dict = root as? [String: Any] ?? [:]
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
dict[key] = child
|
||||
root = dict
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
var array = root as? [Any] ?? []
|
||||
if index >= array.count {
|
||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||
|
||||
@@ -214,9 +214,10 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
@@ -264,12 +265,14 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdbotNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
configRoot: configRoot,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
@@ -384,8 +387,11 @@ enum CommandResolver {
|
||||
let cliPath: String
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
static func connectionSettings(
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||
{
|
||||
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
|
||||
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
|
||||
for segment in path {
|
||||
guard let node = current else { return nil }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
if node.schemaType == "object" {
|
||||
if let next = node.properties[key] {
|
||||
current = next
|
||||
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
|
||||
var match = true
|
||||
for (index, seg) in segments.enumerated() {
|
||||
let hintSegment = hintSegments[index]
|
||||
if hintSegment != "*" && hintSegment != seg {
|
||||
if hintSegment != "*", hintSegment != seg {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
case .key(let key): return key
|
||||
case let .key(key): return key
|
||||
case .index: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if self.store.configDirty && !self.isNixMode {
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -25,8 +25,14 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major = Int(parts[0]),
|
||||
let minor = Int(parts[1])
|
||||
else { return nil }
|
||||
let patch = Int(parts[2]) ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patch)
|
||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||
let patchRaw = String(parts[2])
|
||||
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||
let patchNumeric = Int(patchToken)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||
}
|
||||
|
||||
func compatible(with required: Semver) -> Bool {
|
||||
@@ -78,8 +84,13 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
Semver.parse(self.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
return Semver.parse(bundleVersion)
|
||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
@@ -98,6 +109,7 @@ enum GatewayEnvironment {
|
||||
}
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
let expectedString = self.expectedGatewayVersionString()
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
@@ -108,8 +120,8 @@ enum GatewayEnvironment {
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
requiredGateway: expectedString,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
|
||||
@@ -118,7 +130,7 @@ enum GatewayEnvironment {
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
@@ -126,13 +138,14 @@ enum GatewayEnvironment {
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let expectedText = expectedString ?? expected.description
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
kind: .incompatible(found: installed.description, required: expectedText),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
requiredGateway: expectedText,
|
||||
message: """
|
||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
||||
install or update the global package.
|
||||
""")
|
||||
}
|
||||
@@ -150,7 +163,7 @@ enum GatewayEnvironment {
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
@@ -218,8 +231,18 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
|
||||
}
|
||||
|
||||
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let target: String
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
target = trimmed
|
||||
} else {
|
||||
target = "latest"
|
||||
}
|
||||
let npm = CommandResolver.findExecutable(named: "npm")
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
@@ -278,8 +301,7 @@ enum GatewayEnvironment {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -294,7 +316,6 @@ enum GatewayEnvironment {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
|
||||
@@ -83,27 +83,7 @@ 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)
|
||||
}
|
||||
SystemRunSettingsView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
|
||||
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
|
||||
@@ -16,9 +16,7 @@ enum Launchctl {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return Result(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
|
||||
@@ -38,39 +38,14 @@ enum MacNodeConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
|
||||
root["systemRun"] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
|
||||
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
|
||||
}
|
||||
var systemRun = self.systemRunSection(from: root)
|
||||
mutate(&systemRun)
|
||||
if systemRun.isEmpty {
|
||||
root.removeValue(forKey: "systemRun")
|
||||
} else {
|
||||
@@ -78,4 +53,147 @@ enum MacNodeConfigFile {
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
|
||||
let agents = systemRun["agents"] as? [String: Any]
|
||||
return agents?[agentId] as? [String: Any]
|
||||
}
|
||||
|
||||
private static func updateAgentSection(
|
||||
_ systemRun: inout [String: Any],
|
||||
agentId: String,
|
||||
mutate: (inout [String: Any]) -> Void)
|
||||
{
|
||||
var agents = systemRun["agents"] as? [String: Any] ?? [:]
|
||||
var entry = agents[agentId] as? [String: Any] ?? [:]
|
||||
mutate(&entry)
|
||||
if entry.isEmpty {
|
||||
agents.removeValue(forKey: agentId)
|
||||
} else {
|
||||
agents[agentId] = entry
|
||||
}
|
||||
if agents.isEmpty {
|
||||
systemRun.removeValue(forKey: "agents")
|
||||
} else {
|
||||
systemRun["agents"] = agents
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
let raw = agent["policy"] as? String
|
||||
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
|
||||
}
|
||||
let raw = systemRun["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
entry["policy"] = policy.rawValue
|
||||
}
|
||||
return
|
||||
}
|
||||
systemRun["policy"] = policy.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
if let value = agent["autoAllowSkills"] as? Bool { return value }
|
||||
}
|
||||
return systemRun["autoAllowSkills"] as? Bool
|
||||
}
|
||||
|
||||
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
entry["autoAllowSkills"] = enabled
|
||||
}
|
||||
return
|
||||
}
|
||||
systemRun["autoAllowSkills"] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
let raw: [Any]? = {
|
||||
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
|
||||
return agent["allowlist"] as? [Any]
|
||||
}
|
||||
return systemRun["allowlist"] as? [Any]
|
||||
}()
|
||||
guard let raw else { return nil }
|
||||
|
||||
if raw.allSatisfy({ $0 is String }) {
|
||||
let legacy = raw.compactMap { $0 as? String }
|
||||
return legacy.compactMap { key in
|
||||
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return nil }
|
||||
return SystemRunAllowlistEntry(
|
||||
pattern: pattern,
|
||||
enabled: true,
|
||||
matchKind: .argv,
|
||||
source: .manual)
|
||||
}
|
||||
}
|
||||
|
||||
return raw.compactMap { item in
|
||||
guard let dict = item as? [String: Any] else { return nil }
|
||||
return SystemRunAllowlistEntry(dict: dict)
|
||||
}
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
|
||||
let cleaned = allowlist
|
||||
.map { $0 }
|
||||
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
let raw = cleaned.map { $0.asDict() }
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if let agentId {
|
||||
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
|
||||
if raw.isEmpty {
|
||||
entry.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
entry["allowlist"] = raw
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if raw.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunAllowlistStrings() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = self.systemRunSection(from: root)
|
||||
return systemRun["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
self.updateSystemRunSection { systemRun in
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,8 +428,32 @@ actor MacNodeRuntime {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
|
||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||
switch Self.systemRunPolicy() {
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
let policy = SystemRunPolicy.load(agentId: agentId)
|
||||
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
|
||||
let allowlistMatch = SystemRunAllowlistStore.match(
|
||||
command: command,
|
||||
resolution: resolution,
|
||||
entries: allowlistEntries)
|
||||
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||
let skillAllow: Bool
|
||||
if autoAllowSkills, let name = resolution?.executableName {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = bins.contains(name)
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
|
||||
let shouldPrompt: Bool = {
|
||||
if policy == .never { return false }
|
||||
if allowlistMatch != nil { return false }
|
||||
if skillAllow { return false }
|
||||
return policy == .ask
|
||||
}()
|
||||
|
||||
switch policy {
|
||||
case .never:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -438,16 +462,24 @@ actor MacNodeRuntime {
|
||||
case .always:
|
||||
break
|
||||
case .ask:
|
||||
if !wasAllowlisted {
|
||||
if shouldPrompt {
|
||||
let services = await self.mainActorServices()
|
||||
let decision = await services.confirmSystemRun(
|
||||
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
|
||||
command: SystemRunAllowlist.displayString(for: command),
|
||||
cwd: params.cwd)
|
||||
cwd: params.cwd,
|
||||
agentId: agentId,
|
||||
executablePath: resolution?.resolvedPath))
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
break
|
||||
case .allowAlways:
|
||||
SystemRunAllowlist.add(command)
|
||||
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
|
||||
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
|
||||
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
{
|
||||
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
|
||||
}
|
||||
case .deny:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -457,6 +489,14 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
SystemRunAllowlistStore.markUsed(
|
||||
entryId: match.id,
|
||||
command: command,
|
||||
resolvedPath: resolution?.resolvedPath,
|
||||
agentId: agentId)
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
|
||||
@@ -9,6 +9,13 @@ enum SystemRunDecision: Sendable {
|
||||
case deny
|
||||
}
|
||||
|
||||
struct SystemRunPromptContext: Sendable {
|
||||
let command: String
|
||||
let cwd: String?
|
||||
let agentId: String?
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
@@ -25,7 +32,7 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -67,16 +74,24 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
func confirmSystemRun(context: SystemRunPromptContext) 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) ?? ""
|
||||
var details = "Clawdbot wants to run:\n\n\(context.command)"
|
||||
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedAgent.isEmpty {
|
||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||
}
|
||||
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPath.isEmpty {
|
||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||
}
|
||||
details += "\n\nThis runs on this Mac via node mode."
|
||||
alert.informativeText = details
|
||||
|
||||
|
||||
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
return "npm install -g clawdbot@\(version)"
|
||||
}
|
||||
|
||||
|
||||
@@ -203,15 +203,13 @@ actor PortGuardian {
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = try proc.runAndReadToEnd(from: pipe)
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Process {
|
||||
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
|
||||
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
|
||||
try self.run()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
self.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,7 @@ enum RuntimeLocator {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -149,7 +148,6 @@ enum RuntimeLocator {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemRunAllowlistMatchKind: String {
|
||||
case glob
|
||||
case argv
|
||||
}
|
||||
|
||||
enum SystemRunAllowlistSource: String {
|
||||
case manual
|
||||
case skill
|
||||
}
|
||||
|
||||
struct SystemRunAllowlistEntry: Identifiable, Hashable {
|
||||
let id: String
|
||||
var pattern: String
|
||||
var enabled: Bool
|
||||
var matchKind: SystemRunAllowlistMatchKind
|
||||
var source: SystemRunAllowlistSource?
|
||||
var skillId: String?
|
||||
var lastUsedAt: Date?
|
||||
var lastUsedCommand: String?
|
||||
var lastUsedPath: String?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
pattern: String,
|
||||
enabled: Bool = true,
|
||||
matchKind: SystemRunAllowlistMatchKind = .glob,
|
||||
source: SystemRunAllowlistSource? = .manual,
|
||||
skillId: String? = nil,
|
||||
lastUsedAt: Date? = nil,
|
||||
lastUsedCommand: String? = nil,
|
||||
lastUsedPath: String? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.pattern = pattern
|
||||
self.enabled = enabled
|
||||
self.matchKind = matchKind
|
||||
self.source = source
|
||||
self.skillId = skillId
|
||||
self.lastUsedAt = lastUsedAt
|
||||
self.lastUsedCommand = lastUsedCommand
|
||||
self.lastUsedPath = lastUsedPath
|
||||
}
|
||||
|
||||
init?(dict: [String: Any]) {
|
||||
let id = dict["id"] as? String ?? UUID().uuidString
|
||||
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if pattern.isEmpty { return nil }
|
||||
let enabled = dict["enabled"] as? Bool ?? true
|
||||
let matchRaw = dict["matchKind"] as? String
|
||||
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
|
||||
let sourceRaw = dict["source"] as? String
|
||||
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
|
||||
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
|
||||
let lastUsedCommand = dict["lastUsedCommand"] as? String
|
||||
let lastUsedPath = dict["lastUsedPath"] as? String
|
||||
|
||||
self.init(
|
||||
id: id,
|
||||
pattern: pattern,
|
||||
enabled: enabled,
|
||||
matchKind: matchKind,
|
||||
source: source,
|
||||
skillId: skillId?.isEmpty == true ? nil : skillId,
|
||||
lastUsedAt: lastUsedAt,
|
||||
lastUsedCommand: lastUsedCommand,
|
||||
lastUsedPath: lastUsedPath)
|
||||
}
|
||||
|
||||
func asDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"id": self.id,
|
||||
"pattern": self.pattern,
|
||||
"enabled": self.enabled,
|
||||
"matchKind": self.matchKind.rawValue,
|
||||
]
|
||||
if let source = self.source { dict["source"] = source.rawValue }
|
||||
if let skillId = self.skillId { dict["skillId"] = skillId }
|
||||
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
|
||||
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
|
||||
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemRunCommandResolution: Sendable {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
|
||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||
let hasPathSeparator = expanded.contains("/")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
return CommandResolver.findExecutable(named: expanded)
|
||||
}()
|
||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunAllowlistStore {
|
||||
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
|
||||
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
|
||||
return entries
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
|
||||
var entries = self.load(agentId: agentId)
|
||||
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
|
||||
entries.append(entry)
|
||||
self.save(entries, agentId: agentId)
|
||||
return entry
|
||||
}
|
||||
|
||||
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
|
||||
var entries = self.load(agentId: agentId)
|
||||
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||
entries[index] = entry
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func remove(entryId: String, agentId: String?) {
|
||||
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
|
||||
var entries = self.load(agentId: agentId)
|
||||
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
|
||||
entries[index].lastUsedAt = Date()
|
||||
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
|
||||
entries[index].lastUsedPath = resolvedPath
|
||||
self.save(entries, agentId: agentId)
|
||||
}
|
||||
|
||||
static func match(
|
||||
command: [String],
|
||||
resolution: SystemRunCommandResolution?,
|
||||
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
|
||||
{
|
||||
guard !entries.isEmpty else { return nil }
|
||||
let argvKey = SystemRunAllowlist.legacyKey(for: command)
|
||||
let resolvedPath = resolution?.resolvedPath
|
||||
let executableName = resolution?.executableName
|
||||
let rawExecutable = resolution?.rawExecutable
|
||||
|
||||
for entry in entries {
|
||||
guard entry.enabled else { continue }
|
||||
switch entry.matchKind {
|
||||
case .argv:
|
||||
if argvKey == entry.pattern { return entry }
|
||||
case .glob:
|
||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if pattern.isEmpty { continue }
|
||||
let hasPath = pattern.contains("/") || pattern.contains("~")
|
||||
if hasPath {
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
|
||||
return entry
|
||||
}
|
||||
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunGlob {
|
||||
static func matches(pattern rawPattern: String, target: String) -> Bool {
|
||||
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||
guard let regex = self.regex(for: expanded) else { return false }
|
||||
let range = NSRange(location: 0, length: target.utf16.count)
|
||||
return regex.firstMatch(in: target, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||
var regex = "^"
|
||||
var idx = pattern.startIndex
|
||||
while idx < pattern.endIndex {
|
||||
let ch = pattern[idx]
|
||||
if ch == "*" {
|
||||
let next = pattern.index(after: idx)
|
||||
if next < pattern.endIndex, pattern[next] == "*" {
|
||||
regex += ".*"
|
||||
idx = pattern.index(after: next)
|
||||
} else {
|
||||
regex += "[^/]*"
|
||||
idx = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "?" {
|
||||
regex += "."
|
||||
idx = pattern.index(after: idx)
|
||||
continue
|
||||
}
|
||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||
idx = pattern.index(after: idx)
|
||||
}
|
||||
regex += "$"
|
||||
return try? NSRegularExpression(pattern: regex)
|
||||
}
|
||||
}
|
||||
|
||||
actor SkillBinsCache {
|
||||
static let shared = SkillBinsCache()
|
||||
|
||||
private var bins: Set<String> = []
|
||||
private var lastRefresh: Date?
|
||||
private let refreshInterval: TimeInterval = 90
|
||||
|
||||
func currentBins(force: Bool = false) async -> Set<String> {
|
||||
if force || self.isStale() {
|
||||
await self.refresh()
|
||||
}
|
||||
return self.bins
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
var next = Set<String>()
|
||||
for skill in report.skills {
|
||||
for bin in skill.requirements.bins {
|
||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||
}
|
||||
}
|
||||
self.bins = next
|
||||
self.lastRefresh = Date()
|
||||
} catch {
|
||||
if self.lastRefresh == nil {
|
||||
self.bins = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isStale() -> Bool {
|
||||
guard let lastRefresh else { return true }
|
||||
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
|
||||
return policy
|
||||
}
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||
return policy
|
||||
}
|
||||
@@ -40,7 +43,7 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
enum SystemRunAllowlist {
|
||||
static func key(for argv: [String]) -> String {
|
||||
static func legacyKey(for argv: [String]) -> String {
|
||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let data = try? JSONEncoder().encode(trimmed),
|
||||
@@ -62,28 +65,14 @@ enum SystemRunAllowlist {
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
|
||||
return Set(allowlist)
|
||||
}
|
||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||
MacNodeConfigFile.setSystemRunAllowlistStrings(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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SystemRunSettingsView: View {
|
||||
@State private var model = SystemRunSettingsModel()
|
||||
@State private var tab: SystemRunSettingsTab = .policy
|
||||
@State private var newPattern: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
Spacer(minLength: 0)
|
||||
if self.model.agentIds.count > 1 {
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentIds, id: \.self) { id in
|
||||
Text(id).tag(id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
ForEach(SystemRunSettingsTab.allCases) { tab in
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 280)
|
||||
|
||||
if self.tab == .policy {
|
||||
self.policyView
|
||||
} else {
|
||||
self.allowlistView
|
||||
}
|
||||
}
|
||||
.task { await self.model.refresh() }
|
||||
.onChange(of: self.tab) { _, _ in
|
||||
Task { await self.model.refreshSkillBins() }
|
||||
}
|
||||
}
|
||||
|
||||
private var policyView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("", selection: Binding(
|
||||
get: { self.model.policy },
|
||||
set: { self.model.setPolicy($0) }))
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private var allowlistView: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Auto-allow skill CLIs", isOn: Binding(
|
||||
get: { self.model.autoAllowSkills },
|
||||
set: { self.model.setAutoAllowSkills($0) }))
|
||||
|
||||
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
|
||||
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
|
||||
SystemRunAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0) }),
|
||||
onRemove: { self.model.removeEntry($0.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
|
||||
case policy
|
||||
case allowlist
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .policy: "Policy"
|
||||
case .allowlist: "Allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemRunAllowlistRow: View {
|
||||
@Binding var entry: SystemRunAllowlistEntry
|
||||
let onRemove: (SystemRunAllowlistEntry) -> Void
|
||||
@State private var draftPattern: String = ""
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("", isOn: self.$entry.enabled)
|
||||
.labelsHidden()
|
||||
|
||||
TextField("Pattern", text: self.patternBinding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if self.entry.matchKind == .argv {
|
||||
Text("Legacy")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
self.onRemove(self.entry)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
if let lastUsedAt = self.entry.lastUsedAt {
|
||||
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last used: \(lastUsedCommand)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.draftPattern = self.entry.pattern
|
||||
}
|
||||
}
|
||||
|
||||
private var patternBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
|
||||
set: { newValue in
|
||||
self.draftPattern = newValue
|
||||
self.entry.pattern = newValue
|
||||
if self.entry.matchKind == .argv {
|
||||
self.entry.matchKind = .glob
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SystemRunSettingsModel {
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
var policy: SystemRunPolicy = .ask
|
||||
var autoAllowSkills = false
|
||||
var entries: [SystemRunAllowlistEntry] = []
|
||||
var skillBins: [String] = []
|
||||
|
||||
func refresh() async {
|
||||
await self.refreshAgents()
|
||||
self.loadSettings(for: self.selectedAgentId)
|
||||
await self.refreshSkillBins()
|
||||
}
|
||||
|
||||
func refreshAgents() async {
|
||||
let root = await ConfigStore.load()
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let list = agents?["list"] as? [[String: Any]] ?? []
|
||||
var ids: [String] = []
|
||||
var seen = Set<String>()
|
||||
var defaultId: String?
|
||||
for entry in list {
|
||||
guard let raw = entry["id"] as? String else { continue }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
if !seen.insert(trimmed).inserted { continue }
|
||||
ids.append(trimmed)
|
||||
if (entry["default"] as? Bool) == true, defaultId == nil {
|
||||
defaultId = trimmed
|
||||
}
|
||||
}
|
||||
if ids.isEmpty {
|
||||
ids = ["main"]
|
||||
defaultId = "main"
|
||||
} else if defaultId == nil {
|
||||
defaultId = ids.first
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
}
|
||||
|
||||
func selectAgent(_ id: String) {
|
||||
self.selectedAgentId = id
|
||||
self.loadSettings(for: id)
|
||||
Task { await self.refreshSkillBins() }
|
||||
}
|
||||
|
||||
func loadSettings(for agentId: String) {
|
||||
self.policy = SystemRunPolicy.load(agentId: agentId)
|
||||
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
|
||||
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
|
||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||
}
|
||||
|
||||
func setPolicy(_ policy: SystemRunPolicy) {
|
||||
self.policy = policy
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
|
||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||
AppStateStore.shared.systemRunPolicy = policy
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoAllowSkills(_ enabled: Bool) {
|
||||
self.autoAllowSkills = enabled
|
||||
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
|
||||
self.entries.append(entry)
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: SystemRunAllowlistEntry) {
|
||||
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
|
||||
self.entries[index] = entry
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func removeEntry(_ id: String) {
|
||||
self.entries.removeAll { $0.id == id }
|
||||
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
|
||||
}
|
||||
|
||||
func refreshSkillBins(force: Bool = false) async {
|
||||
guard self.autoAllowSkills else {
|
||||
self.skillBins = []
|
||||
return
|
||||
}
|
||||
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||
self.skillBins = bins.sorted()
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import Testing
|
||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||
try self.makeExec(at: clawdbotPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||
|
||||
#expect(cmd.count >= 3)
|
||||
@@ -75,7 +76,7 @@ import Testing
|
||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||
try self.makeExec(at: pnpmPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||
}
|
||||
@@ -93,7 +94,8 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "health",
|
||||
extraArgs: ["--json", "--timeout", "5"],
|
||||
defaults: defaults)
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||
@@ -114,7 +116,11 @@ import Testing
|
||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "status",
|
||||
extraArgs: ["--json"],
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
#expect(cmd.contains("clawd@example.com"))
|
||||
|
||||
@@ -5,16 +5,28 @@ import Testing
|
||||
@Suite struct GatewayEnvironmentTests {
|
||||
@Test func semverParsesCommonForms() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||
#expect(Semver.parse(nil) == nil)
|
||||
#expect(Semver.parse("invalid") == nil)
|
||||
#expect(Semver.parse("1.2") == nil)
|
||||
#expect(Semver.parse("1.2.x") == nil)
|
||||
}
|
||||
|
||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
||||
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
|
||||
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
@@ -36,6 +48,7 @@ import Testing
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ struct MacNodeRuntimeTests {
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
|
||||
.allowOnce
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
struct SystemRunAllowlistTests {
|
||||
@Test func matchUsesResolvedPath() {
|
||||
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
|
||||
let resolution = SystemRunCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["rg"],
|
||||
resolution: resolution,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
|
||||
@Test func matchUsesBasenameForSimplePattern() {
|
||||
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
|
||||
let resolution = SystemRunCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["rg"],
|
||||
resolution: resolution,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
|
||||
@Test func matchUsesLegacyArgvKey() {
|
||||
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
|
||||
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
|
||||
let match = SystemRunAllowlistStore.match(
|
||||
command: ["echo", "hi"],
|
||||
resolution: nil,
|
||||
entries: [entry])
|
||||
#expect(match?.id == entry.id)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import Testing
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||
#expect(settings.mode == .remote)
|
||||
#expect(settings.target == "alice@example.com")
|
||||
}
|
||||
|
||||
@@ -24,19 +24,22 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
public var needsScreenRecording: Bool?
|
||||
public var agentId: String?
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
needsScreenRecording: Bool? = nil)
|
||||
needsScreenRecording: Bool? = nil,
|
||||
agentId: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
self.needsScreenRecording = needsScreenRecording
|
||||
self.agentId = agentId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Brave Search API setup for web_search"
|
||||
read_when:
|
||||
- You want to use Brave Search for web_search
|
||||
- You need a BRAVE_API_KEY or plan details
|
||||
---
|
||||
|
||||
# Brave Search API
|
||||
|
||||
Clawdbot uses Brave Search as the default provider for `web_search`.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||
3) Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
@@ -58,7 +58,7 @@ Minimal config:
|
||||
- The `discord` tool is only exposed when the current channel is Discord.
|
||||
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions.
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
@@ -175,6 +175,7 @@ Notes:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
|
||||
@@ -192,8 +193,11 @@ Notes:
|
||||
- Your config requires mentions and you didn’t mention it, or
|
||||
- Your guild/channel allowlist denies the channel/user.
|
||||
- **`requireMention: false` but still no replies**:
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
|
||||
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
|
||||
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
|
||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||
|
||||
@@ -361,10 +365,15 @@ Allowlist matching notes:
|
||||
- Use `*` to allow any sender/channel.
|
||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
||||
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
|
||||
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
|
||||
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
|
||||
@@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
- `clawdbot pairing list matrix`
|
||||
- `clawdbot pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
|
||||
|
||||
## Rooms (groups)
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Allowlist rooms with `channels.matrix.rooms`:
|
||||
```json5
|
||||
{
|
||||
@@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
}
|
||||
```
|
||||
- `requireMention: false` enables auto-reply in that room.
|
||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
||||
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
|
||||
## Threads
|
||||
- Reply threading is supported.
|
||||
|
||||
@@ -76,12 +76,13 @@ Disable with:
|
||||
|
||||
**DM access**
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||
|
||||
**Group access**
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
||||
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
@@ -95,6 +96,32 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
**Teams + channel allowlist**
|
||||
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
||||
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
|
||||
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
||||
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
||||
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
teams: {
|
||||
"My Team": {
|
||||
channels: {
|
||||
"General": { requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
1. Install the Microsoft Teams plugin.
|
||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||
|
||||
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
|
||||
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
|
||||
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
|
||||
@@ -342,10 +343,19 @@ For fine-grained control, use these tags in agent responses:
|
||||
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
|
||||
|
||||
## Group policy
|
||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||
- `allowlist` requires channels to be listed in `channels.slack.channels`.
|
||||
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
|
||||
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
|
||||
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
|
||||
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
|
||||
(public + private); if multiple matches exist, it prefers the active channel.
|
||||
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
|
||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||
|
||||
@@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
|
||||
```
|
||||
|
||||
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
||||
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
|
||||
|
||||
To allow all groups with always-respond:
|
||||
```json5
|
||||
@@ -216,6 +217,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
|
||||
|
||||
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
||||
|
||||
@@ -360,6 +362,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
|
||||
|
||||
The tag is stripped from the delivered text. Other channels ignore this tag.
|
||||
|
||||
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
|
||||
(`message` is optional when media is present):
|
||||
|
||||
```json5
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "telegram",
|
||||
"to": "123456789",
|
||||
"media": "https://example.com/voice.ogg",
|
||||
"asVoice": true
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming (drafts)
|
||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||
|
||||
@@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work"
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
- `clawdbot pairing approve zalouser <code>`
|
||||
|
||||
## Group access (optional)
|
||||
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Restrict to an allowlist with:
|
||||
- `channels.zalouser.groupPolicy = "allowlist"`
|
||||
- `channels.zalouser.groups` (keys are group IDs or names)
|
||||
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
||||
- The configure wizard can prompt for group allowlists.
|
||||
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"123456789": { allow: true },
|
||||
"Work Chat": { allow: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
Accounts map to zca profiles. Example:
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ Related docs:
|
||||
```bash
|
||||
clawdbot channels list
|
||||
clawdbot channels status
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||
clawdbot channels logs --channel all
|
||||
```
|
||||
|
||||
@@ -42,3 +45,30 @@ clawdbot channels logout --channel whatsapp
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
Fetch provider capability hints (intents/scopes where available) plus static feature support:
|
||||
|
||||
```bash
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||
|
||||
## Resolve names to IDs
|
||||
|
||||
Resolve channel/user names to IDs using the provider directory:
|
||||
|
||||
```bash
|
||||
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||
clawdbot channels resolve --channel discord "My Server/#support" "@someone"
|
||||
clawdbot channels resolve --channel matrix "Project Room"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use `--kind user|group|auto` to force the target type.
|
||||
- Resolution prefers active matches when multiple entries share the same name.
|
||||
@@ -17,6 +17,7 @@ Related:
|
||||
|
||||
Notes:
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
@@ -29,6 +29,7 @@ Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
|
||||
+3
-2
@@ -292,7 +292,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -302,6 +302,7 @@ Options:
|
||||
- `--openrouter-api-key <key>`
|
||||
- `--ai-gateway-api-key <key>`
|
||||
- `--moonshot-api-key <key>`
|
||||
- `--kimi-code-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
@@ -527,7 +528,7 @@ Surfaces:
|
||||
|
||||
Notes:
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
|
||||
@@ -8,15 +8,23 @@ read_when:
|
||||
# `clawdbot memory`
|
||||
|
||||
Memory search tools (semantic memory status/index/search).
|
||||
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
|
||||
|
||||
Related:
|
||||
- Memory concept: [Memory](/concepts/memory)
|
||||
- Plugins: [Plugins](/plugins)
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot memory status
|
||||
clawdbot memory status --deep
|
||||
clawdbot memory status --deep --index
|
||||
clawdbot memory status --deep --index --verbose
|
||||
clawdbot memory index
|
||||
clawdbot memory search "release checklist"
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||
@@ -26,6 +26,11 @@ clawdbot models scan
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
Notes:
|
||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,6 +25,9 @@ clawdbot plugins update <id>
|
||||
clawdbot plugins update --all
|
||||
```
|
||||
|
||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,3 +19,4 @@ clawdbot status --usage
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
@@ -23,9 +25,13 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||
streams tool output via agent events when available.
|
||||
More details: [Streaming + chunking](/concepts/streaming).
|
||||
|
||||
## Model refs
|
||||
|
||||
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
|
||||
|
||||
- Use `provider/model` when configuring models.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
|
||||
+123
-2
@@ -9,6 +9,9 @@ read_when:
|
||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
||||
source of truth; the model only "remembers" what gets written to disk.
|
||||
|
||||
Memory search tools are provided by the active memory plugin (default:
|
||||
`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`.
|
||||
|
||||
## Memory files (Markdown)
|
||||
|
||||
The default workspace layout uses two memory layers:
|
||||
@@ -78,6 +81,7 @@ Defaults:
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
@@ -107,6 +111,19 @@ agents: {
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Batch indexing (OpenAI only):
|
||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||
|
||||
Why OpenAI batch is fast + cheap:
|
||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
|
||||
- See the OpenAI Batch API docs and pricing for details:
|
||||
- https://platform.openai.com/docs/api-reference/batch
|
||||
- https://platform.openai.com/pricing
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
@@ -116,6 +133,9 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
remote: {
|
||||
batch: { enabled: true, concurrency: 2 }
|
||||
},
|
||||
sync: { watch: true }
|
||||
}
|
||||
}
|
||||
@@ -140,8 +160,109 @@ Local mode:
|
||||
### What gets indexed (and when)
|
||||
|
||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
|
||||
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||
|
||||
### Hybrid search (BM25 + vector)
|
||||
|
||||
When enabled, Clawdbot combines:
|
||||
- **Vector similarity** (semantic match, wording can differ)
|
||||
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
|
||||
|
||||
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
query: {
|
||||
hybrid: {
|
||||
enabled: true,
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
candidateMultiplier: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding cache
|
||||
|
||||
Clawdbot can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
cache: {
|
||||
enabled: true,
|
||||
maxEntries: 50000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session memory search (experimental)
|
||||
|
||||
You can optionally index **session transcripts** and surface them via `memory_search`.
|
||||
This is gated behind an experimental flag.
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
experimental: { sessionMemory: true },
|
||||
sources: ["memory", "sessions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Session indexing is **opt-in** (off by default).
|
||||
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
|
||||
- Results still include snippets only; `memory_get` remains limited to memory files.
|
||||
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
||||
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
||||
|
||||
### SQLite vector acceleration (sqlite-vec)
|
||||
|
||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||
SQLite virtual table (`vec0`) and performs vector distance queries in the
|
||||
database. This keeps search fast without loading every embedding into JS.
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
store: {
|
||||
vector: {
|
||||
enabled: true,
|
||||
extensionPath: "/path/to/sqlite-vec"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `enabled` defaults to true; when disabled, search falls back to in-process
|
||||
cosine similarity over stored embeddings.
|
||||
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
|
||||
error and continues with the JS fallback (no vector table).
|
||||
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
|
||||
or non-standard install locations).
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
|
||||
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-antigravity-auth`
|
||||
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
@@ -150,6 +155,50 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
}
|
||||
```
|
||||
|
||||
### Kimi Code
|
||||
|
||||
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
|
||||
|
||||
- Provider: `kimi-code`
|
||||
- Auth: `KIMICODE_API_KEY`
|
||||
- Example model: `kimi-code/kimi-for-coding`
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Qwen OAuth (free tier)
|
||||
|
||||
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||
Enable the bundled plugin, then log in:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable qwen-portal-auth
|
||||
clawdbot models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
Model refs:
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
See [/providers/qwen](/providers/qwen) for setup details and notes.
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
@@ -102,6 +102,9 @@ Notes:
|
||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||
- `/model <#>` selects from that picker.
|
||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
||||
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
|
||||
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||
|
||||
## Session pruning
|
||||
@@ -113,3 +114,11 @@ Send these as standalone messages so they register.
|
||||
## Tips
|
||||
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
||||
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
|
||||
|
||||
## Session origin metadata
|
||||
Each session entry records where it came from (best-effort) in `origin`:
|
||||
- `label`: human label (resolved from conversation label + group subject/channel)
|
||||
- `provider`: normalized channel id (including extensions)
|
||||
- `from`/`to`: raw routing ids from the inbound envelope
|
||||
- `accountId`: provider account id (when multi-account)
|
||||
- `threadId`: thread/topic id when the channel supports it
|
||||
@@ -894,7 +894,6 @@
|
||||
"gateway/heartbeat",
|
||||
"gateway/doctor",
|
||||
"gateway/logging",
|
||||
"logging",
|
||||
"gateway/security",
|
||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||
"gateway/sandboxing",
|
||||
|
||||
@@ -33,6 +33,7 @@ When spawning long-running child processes outside the exec/process tools (for e
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
||||
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
|
||||
@@ -678,10 +678,11 @@ Notes:
|
||||
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
||||
- `"disabled"`: block all group/room messages.
|
||||
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
||||
- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset.
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
||||
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
||||
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
|
||||
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
|
||||
|
||||
### Multi-agent routing (`agents.list` + `bindings`)
|
||||
|
||||
@@ -2234,6 +2235,49 @@ Notes:
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
|
||||
### Kimi Code
|
||||
|
||||
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kimi-code/kimi-for-coding" },
|
||||
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-for-coding",
|
||||
name: "Kimi For Coding",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 32768,
|
||||
headers: { "User-Agent": "KimiCLI/0.77" },
|
||||
compat: { supportsDeveloperRole: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
|
||||
- Model ref: `kimi-code/kimi-for-coding`.
|
||||
|
||||
### Synthetic (Anthropic-compatible)
|
||||
|
||||
Use Synthetic's Anthropic-compatible endpoint:
|
||||
|
||||
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
|
||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||
|
||||
## Local session logs live on disk
|
||||
|
||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||
This is required for session continuity and (optionally) session memory indexing, but it also means
|
||||
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
|
||||
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
|
||||
stronger isolation between agents, run them under separate OS users or separate hosts.
|
||||
|
||||
## Node execution (system.run)
|
||||
|
||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||
|
||||
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||
|
||||
To stay on the beta channel for CLI updates:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel beta
|
||||
```
|
||||
|
||||
Switch back to stable later:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
## Update (Control UI / RPC)
|
||||
|
||||
|
||||
@@ -62,8 +62,25 @@ read_when:
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-only (Deepgram)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
|
||||
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
|
||||
@@ -32,6 +32,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
- `tools.media.models`: shared model list (use `capabilities` to gate).
|
||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
@@ -108,6 +110,7 @@ lists, Clawdbot can infer defaults:
|
||||
- `openai`, `anthropic`, `minimax`: **image**
|
||||
- `google` (Gemini API): **image + audio + video**
|
||||
- `groq`: **audio**
|
||||
- `deepgram`: **audio**
|
||||
|
||||
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
||||
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
@@ -116,7 +119,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -125,8 +128,9 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
summary: "Perplexity Sonar setup for web_search"
|
||||
read_when:
|
||||
- You want to use Perplexity Sonar for web search
|
||||
- You need PERPLEXITY_API_KEY or OpenRouter setup
|
||||
---
|
||||
|
||||
# Perplexity Sonar
|
||||
|
||||
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
|
||||
through Perplexity’s direct API or via OpenRouter.
|
||||
|
||||
## API options
|
||||
|
||||
### Perplexity (direct)
|
||||
|
||||
- Base URL: https://api.perplexity.ai
|
||||
- Environment variable: `PERPLEXITY_API_KEY`
|
||||
|
||||
### OpenRouter (alternative)
|
||||
|
||||
- Base URL: https://openrouter.ai/api/v1
|
||||
- Environment variable: `OPENROUTER_API_KEY`
|
||||
- Supports prepaid/crypto credits.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Switching from Brave
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
|
||||
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
|
||||
to disambiguate.
|
||||
|
||||
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
|
||||
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
|
||||
|
||||
## Models
|
||||
|
||||
- `perplexity/sonar` — fast Q&A with web search
|
||||
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
|
||||
- `perplexity/sonar-reasoning-pro` — deep research
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
+38
-5
@@ -36,11 +36,16 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
## Available plugins (official)
|
||||
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
@@ -58,16 +63,26 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
|
||||
Clawdbot scans, in order:
|
||||
|
||||
1) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
1) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
|
||||
2) Workspace extensions
|
||||
- `<workspace>/.clawdbot/extensions/*.ts`
|
||||
- `<workspace>/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
3) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
3) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
|
||||
- `<clawdbot>/extensions/*`
|
||||
|
||||
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
||||
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
||||
but can be disabled the same way.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
|
||||
### Package packs
|
||||
|
||||
@@ -123,6 +138,24 @@ Fields:
|
||||
|
||||
Config changes **require a gateway restart**.
|
||||
|
||||
## Plugin slots (exclusive categories)
|
||||
|
||||
Some plugin categories are **exclusive** (only one active at a time). Use
|
||||
`plugins.slots` to select which plugin owns the slot:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
slots: {
|
||||
memory: "memory-core" // or "none" to disable memory plugins
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If multiple plugins declare `kind: "memory"`, only the selected one loads. Others
|
||||
are disabled with diagnostics.
|
||||
|
||||
## Control UI (schema + labels)
|
||||
|
||||
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
summary: "Deepgram transcription for inbound voice notes"
|
||||
read_when:
|
||||
- You want Deepgram speech-to-text for audio attachments
|
||||
- You need a quick Deepgram config example
|
||||
---
|
||||
# Deepgram (Audio Transcription)
|
||||
|
||||
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
|
||||
transcription** via `tools.media.audio`.
|
||||
|
||||
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
|
||||
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
|
||||
it uses the pre-recorded transcription endpoint.
|
||||
|
||||
Website: https://deepgram.com
|
||||
Docs: https://developers.deepgram.com
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Set your API key:
|
||||
```
|
||||
DEEPGRAM_API_KEY=dg_...
|
||||
```
|
||||
|
||||
2) Enable the provider:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `model`: Deepgram model id (default: `nova-3`)
|
||||
- `language`: language hint (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
|
||||
|
||||
Example with language:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "deepgram", model: "nova-3", language: "en" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with Deepgram options:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
providerOptions: {
|
||||
deepgram: {
|
||||
detect_language: true,
|
||||
punctuate: true,
|
||||
smart_format: true
|
||||
}
|
||||
},
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
|
||||
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
|
||||
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
|
||||
read_when:
|
||||
- You want to use GitHub Copilot as a model provider
|
||||
- You need the `clawdbot models auth login-github-copilot` flow
|
||||
---
|
||||
# Github Copilot
|
||||
|
||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||
profile.
|
||||
|
||||
## CLI setup
|
||||
|
||||
```bash
|
||||
clawdbot models auth login-github-copilot
|
||||
```
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
|
||||
### Optional flags
|
||||
|
||||
```bash
|
||||
clawdbot models auth login-github-copilot --profile-id github-copilot:work
|
||||
clawdbot models auth login-github-copilot --yes
|
||||
```
|
||||
|
||||
## Set a default model
|
||||
|
||||
```bash
|
||||
clawdbot models set github-copilot/gpt-4o
|
||||
```
|
||||
|
||||
### Config snippet
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires an interactive TTY; run it directly in a terminal.
|
||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||
another ID (for example `github-copilot/gpt-4.1`).
|
||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||
Copilot API token when Clawdbot runs.
|
||||
@@ -26,13 +26,18 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
||||
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Qwen (OAuth)](/providers/qwen)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
- [Deepgram (audio transcription)](/providers/deepgram)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
## Notes
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
|
||||
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||
|
||||
@@ -26,7 +26,7 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
---
|
||||
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
|
||||
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
|
||||
read_when:
|
||||
- You want to use Moonshot/Kimi models in Clawdbot
|
||||
- You need the Moonshot auth + config example
|
||||
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
|
||||
- You need to understand separate endpoints, keys, and model refs
|
||||
- You want copy/paste config for either provider
|
||||
---
|
||||
|
||||
# Moonshot AI (Kimi)
|
||||
|
||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
|
||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
|
||||
Kimi Code with `kimi-code/kimi-for-coding`.
|
||||
|
||||
Current Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-ids:start */}
|
||||
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
|
||||
clawdbot onboard --auth-choice moonshot-api-key
|
||||
```
|
||||
|
||||
## Config snippet
|
||||
Kimi Code:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice kimi-code-api-key
|
||||
```
|
||||
|
||||
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
|
||||
|
||||
## Config snippet (Moonshot API)
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
}
|
||||
```
|
||||
|
||||
## Kimi Code
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "kimi-code/kimi-for-coding" },
|
||||
models: {
|
||||
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
|
||||
}
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "kimi-for-coding",
|
||||
name: "Kimi For Coding",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 32768,
|
||||
headers: { "User-Agent": "KimiCLI/0.77" },
|
||||
compat: { supportsDeveloperRole: false }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Model refs use `moonshot/<modelId>`.
|
||||
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
|
||||
- Override pricing and context metadata in `models.providers` if needed.
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
summary: "Use Qwen OAuth (free tier) in Clawdbot"
|
||||
read_when:
|
||||
- You want to use Qwen with Clawdbot
|
||||
- You want free-tier OAuth access to Qwen Coder
|
||||
---
|
||||
# Qwen
|
||||
|
||||
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
|
||||
(2,000 requests/day, subject to Qwen rate limits).
|
||||
|
||||
## Enable the plugin
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable qwen-portal-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
This runs the Qwen device-code OAuth flow and writes a provider entry to your
|
||||
`models.json` (plus a `qwen` alias for quick switching).
|
||||
|
||||
## Model IDs
|
||||
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
Switch models with:
|
||||
|
||||
```bash
|
||||
clawdbot models set qwen-portal/coder-model
|
||||
```
|
||||
|
||||
## Reuse Qwen Code CLI login
|
||||
|
||||
If you already logged in with the Qwen Code CLI, Clawdbot will sync credentials
|
||||
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
|
||||
`models.providers.qwen-portal` entry (use the login command above to create one).
|
||||
|
||||
## Notes
|
||||
|
||||
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
|
||||
- Default base URL: `https://portal.qwen.ai/v1` (override with
|
||||
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
|
||||
- See [Model providers](/concepts/model-providers) for provider-wide rules.
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
summary: "Plan: one clean plugin SDK + runtime for all messaging connectors"
|
||||
read_when:
|
||||
- Defining or refactoring the plugin architecture
|
||||
- Migrating channel connectors to the plugin SDK/runtime
|
||||
---
|
||||
# Plugin SDK + Runtime Refactor Plan
|
||||
|
||||
Goal: every messaging connector is a plugin (bundled or external) using one stable API.
|
||||
No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime.
|
||||
|
||||
## Why now
|
||||
- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers.
|
||||
- This makes upgrades brittle and blocks a clean external plugin surface.
|
||||
|
||||
## Target architecture (two layers)
|
||||
|
||||
### 1) Plugin SDK (compile-time, stable, publishable)
|
||||
Scope: types, helpers, and config utilities. No runtime state, no side effects.
|
||||
|
||||
Contents (examples):
|
||||
- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`.
|
||||
- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`,
|
||||
`applyAccountNameToChannelSection`.
|
||||
- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`.
|
||||
- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types.
|
||||
- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`.
|
||||
- Docs link helper: `formatDocsLink`.
|
||||
|
||||
Delivery:
|
||||
- Publish as `@clawdbot/plugin-sdk` (or export from core under `clawdbot/plugin-sdk`).
|
||||
- Semver with explicit stability guarantees.
|
||||
|
||||
### 2) Plugin Runtime (execution surface, injected)
|
||||
Scope: everything that touches core runtime behavior.
|
||||
Accessed via `ClawdbotPluginApi.runtime` so plugins never import `src/**`.
|
||||
|
||||
Proposed surface (minimal but complete):
|
||||
```ts
|
||||
export type PluginRuntime = {
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText(text: string, limit: number): string[];
|
||||
resolveTextChunkLimit(cfg: ClawdbotConfig, channel: string, accountId?: string): number;
|
||||
hasControlCommand(text: string, cfg: ClawdbotConfig): boolean;
|
||||
};
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) =>
|
||||
void | Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}): Promise<void>;
|
||||
createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows
|
||||
};
|
||||
routing: {
|
||||
resolveAgentRoute(params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}): { sessionKey: string; accountId: string };
|
||||
};
|
||||
pairing: {
|
||||
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
|
||||
readAllowFromStore(channel: string): Promise<string[]>;
|
||||
upsertPairingRequest(params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}): Promise<{ code: string; created: boolean }>;
|
||||
};
|
||||
media: {
|
||||
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer(
|
||||
buffer: Uint8Array,
|
||||
contentType: string | undefined,
|
||||
direction: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
): Promise<{ path: string; contentType?: string }>;
|
||||
};
|
||||
mentions: {
|
||||
buildMentionRegexes(cfg: ClawdbotConfig, agentId?: string): RegExp[];
|
||||
matchesMentionPatterns(text: string, regexes: RegExp[]): boolean;
|
||||
};
|
||||
groups: {
|
||||
resolveGroupPolicy(cfg: ClawdbotConfig, channel: string, accountId: string, groupId: string): {
|
||||
allowlistEnabled: boolean;
|
||||
allowed: boolean;
|
||||
groupConfig?: unknown;
|
||||
defaultConfig?: unknown;
|
||||
};
|
||||
resolveRequireMention(
|
||||
cfg: ClawdbotConfig,
|
||||
channel: string,
|
||||
accountId: string,
|
||||
groupId: string,
|
||||
override?: boolean,
|
||||
): boolean;
|
||||
};
|
||||
debounce: {
|
||||
createInboundDebouncer<T>(opts: {
|
||||
debounceMs: number;
|
||||
buildKey: (v: T) => string | null;
|
||||
shouldDebounce: (v: T) => boolean;
|
||||
onFlush: (entries: T[]) => Promise<void>;
|
||||
onError?: (err: unknown) => void;
|
||||
}): { push: (v: T) => void; flush: () => Promise<void> };
|
||||
resolveInboundDebounceMs(cfg: ClawdbotConfig, channel: string): number;
|
||||
};
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}): boolean;
|
||||
};
|
||||
};
|
||||
logging: {
|
||||
shouldLogVerbose(): boolean;
|
||||
getChildLogger(name: string): PluginLogger;
|
||||
};
|
||||
state: {
|
||||
resolveStateDir(cfg: ClawdbotConfig): string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Runtime is the only way to access core behavior.
|
||||
- SDK is intentionally small and stable.
|
||||
- Each runtime method maps to an existing core implementation (no duplication).
|
||||
|
||||
## Migration plan (phased, safe)
|
||||
|
||||
### Phase 0: scaffolding
|
||||
- Introduce `@clawdbot/plugin-sdk`.
|
||||
- Add `api.runtime` to `ClawdbotPluginApi` with the surface above.
|
||||
- Maintain existing imports during a transition window (deprecation warnings).
|
||||
|
||||
### Phase 1: bridge cleanup (low risk)
|
||||
- Replace per-extension `core-bridge.ts` with `api.runtime`.
|
||||
- Migrate BlueBubbles, Zalo, Zalo Personal first (already close).
|
||||
- Remove duplicated bridge code.
|
||||
|
||||
### Phase 2: light direct-import plugins
|
||||
- Migrate Matrix to SDK + runtime.
|
||||
- Validate onboarding, directory, group mention logic.
|
||||
|
||||
### Phase 3: heavy direct-import plugins
|
||||
- Migrate MS Teams (largest set of runtime helpers).
|
||||
- Ensure reply/typing semantics match current behavior.
|
||||
|
||||
### Phase 4: iMessage pluginization
|
||||
- Move iMessage into `extensions/imessage`.
|
||||
- Replace direct core calls with `api.runtime`.
|
||||
- Keep config keys, CLI behavior, and docs intact.
|
||||
|
||||
### Phase 5: enforcement
|
||||
- Add lint rule / CI check: no `extensions/**` imports from `src/**`.
|
||||
- Add plugin SDK/version compatibility checks (runtime + SDK semver).
|
||||
|
||||
## Compatibility and versioning
|
||||
- SDK: semver, published, documented changes.
|
||||
- Runtime: versioned per core release. Add `api.runtime.version`.
|
||||
- Plugins declare a required runtime range (e.g., `clawdbotRuntime: ">=2026.2.0"`).
|
||||
|
||||
## Testing strategy
|
||||
- Adapter-level unit tests (runtime functions exercised with real core implementation).
|
||||
- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating).
|
||||
- A single end-to-end plugin sample used in CI (install + run + smoke).
|
||||
|
||||
## Open questions
|
||||
- Where to host SDK types: separate package or core export?
|
||||
- Runtime type distribution: in SDK (types only) or in core?
|
||||
- How to expose docs links for bundled vs external plugins?
|
||||
- Do we allow limited direct core imports for in-repo plugins during transition?
|
||||
|
||||
## Success criteria
|
||||
- All channel connectors are plugins using SDK + runtime.
|
||||
- No `extensions/**` imports from `src/**`.
|
||||
- New connector templates depend only on SDK + runtime.
|
||||
- External plugins can be developed and updated without core source access.
|
||||
|
||||
Related docs: [Plugins](/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
|
||||
@@ -10,6 +10,12 @@ read_when:
|
||||
|
||||
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
|
||||
|
||||
## Operator trigger
|
||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||
- Read this doc and `docs/platforms/mac/release.md`.
|
||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
1) **Version & metadata**
|
||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||
@@ -20,6 +26,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
2) **Build & artifacts**
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
3) **Changelog & docs**
|
||||
@@ -51,7 +58,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] `npm login` (verify 2FA) if needed.
|
||||
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
||||
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
|
||||
|
||||
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
- **Moonshot (Kimi K2)**: config is auto-written.
|
||||
- More detail: [Moonshot AI](/providers/moonshot)
|
||||
- **Kimi Code**: config is auto-written.
|
||||
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||
- **Skip**: no auth configured yet.
|
||||
- Pick a default model from detected options (or enter provider/model manually).
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
@@ -292,6 +293,7 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
|
||||
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
||||
- `gateway.*` (mode, bind, auth, tailscale)
|
||||
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
|
||||
- Channel allowlists (Slack/Discord/Matrix/Microsoft Teams) when you opt in during the prompts (names resolve to IDs when possible).
|
||||
- `skills.install.nodeManager`
|
||||
- `wizard.lastRunAt`
|
||||
- `wizard.lastRunVersion`
|
||||
|
||||
@@ -290,6 +290,11 @@ Live tests discover credentials the same way the CLI does. Practical implication
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
## Deepgram live (audio transcription)
|
||||
|
||||
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
|
||||
## Docker runners (optional “works in Linux” checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
|
||||
read_when:
|
||||
- Configuring exec approvals or allowlists
|
||||
- Implementing exec approval UX in the macOS app
|
||||
- Reviewing sandbox escape prompts and implications
|
||||
---
|
||||
|
||||
# Exec approvals (macOS app)
|
||||
|
||||
Exec approvals are the **macOS companion app** guardrail for running host
|
||||
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
|
||||
approval layer: the agent asks, the app decides, and the command runs (or not).
|
||||
This is **in addition** to tool policy and elevated gating; all of those checks
|
||||
must pass before a command can run.
|
||||
|
||||
If you are **not** running the macOS companion app, exec approvals are
|
||||
unavailable and `system.run` requests will be rejected with a message that a
|
||||
companion app is required.
|
||||
|
||||
## Settings
|
||||
|
||||
In the macOS app, each agent has an **Exec approvals** setting:
|
||||
|
||||
- **Deny**: block all host exec requests from the agent.
|
||||
- **Always ask**: show a confirmation dialog for each host exec request.
|
||||
- **Always allow**: run host exec requests without prompting.
|
||||
|
||||
Optional toggles:
|
||||
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
|
||||
treated as allowlisted (see below).
|
||||
|
||||
## Allowlist (per agent)
|
||||
|
||||
The allowlist is **per agent**. If multiple agents exist, you can switch which
|
||||
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
|
||||
|
||||
Examples:
|
||||
- `~/Projects/**/bin/bird`
|
||||
- `~/.local/bin/*`
|
||||
- `/opt/homebrew/bin/rg`
|
||||
|
||||
Each allowlist entry tracks:
|
||||
- **last used** (timestamp)
|
||||
- **last used command**
|
||||
- **last used path** (resolved absolute path)
|
||||
- **last seen metadata** (hash/version/mtime when available)
|
||||
|
||||
## How matching works
|
||||
|
||||
1) Parse the command to determine the executable (first token).
|
||||
2) Resolve the executable to an absolute path using `PATH`.
|
||||
3) Match against denylist (if present) → **deny**.
|
||||
4) Match against allowlist → **allow**.
|
||||
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
|
||||
|
||||
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
|
||||
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
|
||||
- the resolved path matches, and
|
||||
- the binary hash/version matches the last approved record (if tracked).
|
||||
|
||||
If the binary changes (new hash/version), the command falls back to **Ask** so
|
||||
the user can re-approve.
|
||||
|
||||
## Approval flow
|
||||
|
||||
When the policy is **Always ask** (or when a binary has changed), the macOS app
|
||||
shows a confirmation dialog. The dialog should include:
|
||||
- command + args
|
||||
- cwd
|
||||
- environment overrides (diff)
|
||||
- policy + rule that matched (if any)
|
||||
|
||||
Actions:
|
||||
- **Allow once** → run now
|
||||
- **Always allow** → add/update allowlist entry + run
|
||||
- **Deny** → block
|
||||
|
||||
When approved, the command runs **in the background** and the agent receives
|
||||
system events as it starts and completes.
|
||||
|
||||
## System events
|
||||
|
||||
The agent receives system messages for observability and recovery:
|
||||
|
||||
- `exec.started` — command accepted and launched
|
||||
- `exec.finished` — command completed (exit code + output)
|
||||
- `exec.denied` — command blocked (policy or denylist)
|
||||
|
||||
These are **system messages**; no extra agent tool call is required to resume.
|
||||
|
||||
## Implications
|
||||
|
||||
- **Always allow** is powerful: the agent can run any host command without a
|
||||
prompt. Prefer allowlisting trusted CLIs instead.
|
||||
- **Ask** keeps you in the loop while still allowing fast approvals.
|
||||
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
|
||||
|
||||
## Storage
|
||||
|
||||
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
|
||||
is a good fit). The Markdown docs describe behavior; they are not the storage
|
||||
mechanism.
|
||||
|
||||
Related:
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Elevated mode](/tools/elevated)
|
||||
- [Skills](/tools/skills)
|
||||
@@ -26,6 +26,11 @@ Note: `elevated` is ignored when sandboxing is off (exec already runs on the hos
|
||||
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
|
||||
## Exec approvals (macOS app)
|
||||
|
||||
Sandboxed agents can require per-request approval before `exec` runs on the host.
|
||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
|
||||
@@ -177,6 +177,7 @@ Notes:
|
||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
|
||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
|
||||
|
||||
### `process`
|
||||
Manage background exec sessions.
|
||||
|
||||
@@ -27,6 +27,7 @@ Tool params:
|
||||
- `label?` (optional)
|
||||
- `agentId?` (optional; spawn under another agent id if allowed)
|
||||
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
||||
- `thinking?` (optional; overrides thinking level for the sub-agent run)
|
||||
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
|
||||
- `cleanup?` (`delete|keep`, default `keep`)
|
||||
|
||||
|
||||
+101
-6
@@ -1,15 +1,16 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API)"
|
||||
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
- You want to use Perplexity Sonar for web search
|
||||
---
|
||||
|
||||
# Web tools
|
||||
|
||||
Clawdbot ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Brave Search API queries (fast, structured results).
|
||||
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (via OpenRouter).
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -17,13 +18,56 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
|
||||
## How it works
|
||||
|
||||
- `web_search` calls Brave’s Search API and returns structured results
|
||||
(title, URL, snippet). No browser is involved.
|
||||
- `web_search` calls your configured provider and returns results.
|
||||
- **Brave** (default): returns structured results (title, URL, snippet).
|
||||
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
|----------|------|------|---------|
|
||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires OpenRouter credits | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||
|
||||
Set the provider in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave" // or "perplexity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example: switch to Perplexity Sonar (direct API):
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
@@ -42,14 +86,65 @@ current limits and pricing.
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## Using Perplexity (direct or via OpenRouter)
|
||||
|
||||
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
|
||||
answers with citations. You can use them via OpenRouter (no credit card required - supports
|
||||
crypto/prepaid).
|
||||
|
||||
### Getting an OpenRouter API key
|
||||
|
||||
1) Create an account at https://openrouter.ai/
|
||||
2) Add credits (supports crypto, prepaid, or credit card)
|
||||
3) Generate an API key in your account settings
|
||||
|
||||
### Setting up Perplexity search
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
|
||||
apiKey: "sk-or-v1-...",
|
||||
// Base URL (defaults to OpenRouter)
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
// Model (defaults to perplexity/sonar-pro)
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env`.
|
||||
|
||||
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
|
||||
Clawdbot defaults to the direct Perplexity endpoint (`https://api.perplexity.ai`).
|
||||
|
||||
### Available Perplexity models
|
||||
|
||||
| Model | Description | Best for |
|
||||
|-------|-------------|----------|
|
||||
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
|
||||
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
||||
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
||||
|
||||
## web_search
|
||||
|
||||
Search the web with Brave’s API.
|
||||
Search the web using your configured provider.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copilot Proxy (Clawdbot plugin)
|
||||
|
||||
Provider plugin for the **Copilot Proxy** VS Code extension.
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable copilot-proxy
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider copilot-proxy --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Copilot Proxy must be running in VS Code.
|
||||
- Base URL must include `/v1`.
|
||||
@@ -0,0 +1,139 @@
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const DEFAULT_MODEL_IDS = [
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5-mini",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok-code-fast-1",
|
||||
] as const;
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return DEFAULT_BASE_URL;
|
||||
let normalized = trimmed;
|
||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateBaseUrl(value: string): string | undefined {
|
||||
const normalized = normalizeBaseUrl(value);
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
return "Enter a valid URL";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseModelIds(input: string): string[] {
|
||||
const parsed = input
|
||||
.split(/[\n,]/)
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(parsed));
|
||||
}
|
||||
|
||||
function buildModelDefinition(modelId: string) {
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
const copilotProxyPlugin = {
|
||||
id: "copilot-proxy",
|
||||
name: "Copilot Proxy",
|
||||
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "copilot-proxy",
|
||||
label: "Copilot Proxy",
|
||||
docsPath: "/providers/models",
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
label: "Local proxy",
|
||||
hint: "Configure base URL + models for the Copilot Proxy server",
|
||||
kind: "custom",
|
||||
run: async (ctx) => {
|
||||
const baseUrlInput = await ctx.prompter.text({
|
||||
message: "Copilot Proxy base URL",
|
||||
initialValue: DEFAULT_BASE_URL,
|
||||
validate: validateBaseUrl,
|
||||
});
|
||||
|
||||
const modelInput = await ctx.prompter.text({
|
||||
message: "Model IDs (comma-separated)",
|
||||
initialValue: DEFAULT_MODEL_IDS.join(", "),
|
||||
validate: (value) =>
|
||||
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
|
||||
});
|
||||
|
||||
const baseUrl = normalizeBaseUrl(baseUrlInput);
|
||||
const modelIds = parseModelIds(modelInput);
|
||||
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
|
||||
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "copilot-proxy:local",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "copilot-proxy",
|
||||
token: DEFAULT_API_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
"copilot-proxy": {
|
||||
baseUrl,
|
||||
apiKey: DEFAULT_API_KEY,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: Object.fromEntries(
|
||||
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: defaultModelRef,
|
||||
notes: [
|
||||
"Start the Copilot Proxy VS Code extension before using these models.",
|
||||
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
|
||||
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default copilotProxyPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Antigravity Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-antigravity-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-antigravity --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Antigravity uses Google Cloud project quotas.
|
||||
- If requests fail, ensure Gemini for Google Cloud is enabled.
|
||||
@@ -0,0 +1,428 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
|
||||
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
|
||||
const CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
const RESPONSE_PAGE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Clawdbot Antigravity OAuth</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Authentication complete</h1>
|
||||
<p>You can return to the terminal.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
||||
const url = new URL(AUTH_URL);
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("code_challenge", params.challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter in URL" };
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: "Paste the full redirect URL (not just the code)." };
|
||||
}
|
||||
}
|
||||
|
||||
async function startCallbackServer(params: { timeoutMs: number }) {
|
||||
const redirect = new URL(REDIRECT_URI);
|
||||
const port = redirect.port ? Number(redirect.port) : 51121;
|
||||
|
||||
let settled = false;
|
||||
let resolveCallback: (url: URL) => void;
|
||||
let rejectCallback: (err: Error) => void;
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
resolveCallback = (url) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(url);
|
||||
};
|
||||
rejectCallback = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
||||
}, params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!request.url) {
|
||||
response.writeHead(400, { "Content-Type": "text/plain" });
|
||||
response.end("Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
||||
if (url.pathname !== redirect.pathname) {
|
||||
response.writeHead(404, { "Content-Type": "text/plain" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(RESPONSE_PAGE);
|
||||
resolveCallback(url);
|
||||
|
||||
setImmediate(() => {
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
reject(err);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCode(params: {
|
||||
code: string;
|
||||
verifier: string;
|
||||
}): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: params.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token exchange failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
const access = data.access_token?.trim();
|
||||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Token exchange returned no access_token");
|
||||
if (!refresh) throw new Error("Token exchange returned no refresh_token");
|
||||
|
||||
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
||||
return { access, refresh, expires };
|
||||
}
|
||||
|
||||
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
async function loginAntigravity(params: {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
}): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
}> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const authUrl = buildAuthUrl({ challenge, state });
|
||||
|
||||
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
||||
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
||||
if (!needsManual) {
|
||||
try {
|
||||
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
||||
} catch {
|
||||
callbackServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callbackServer) {
|
||||
await params.note(
|
||||
[
|
||||
"Open the URL in your local browser.",
|
||||
"After signing in, copy the full redirect URL and paste it back here.",
|
||||
"",
|
||||
`Auth URL: ${authUrl}`,
|
||||
`Redirect URI: ${REDIRECT_URI}`,
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
}
|
||||
|
||||
if (!needsManual) {
|
||||
params.progress.update("Opening Google sign-in…");
|
||||
try {
|
||||
await params.openUrl(authUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let code = "";
|
||||
let returnedState = "";
|
||||
|
||||
if (callbackServer) {
|
||||
params.progress.update("Waiting for OAuth callback…");
|
||||
const callback = await callbackServer.waitForCallback();
|
||||
code = callback.searchParams.get("code") ?? "";
|
||||
returnedState = callback.searchParams.get("state") ?? "";
|
||||
await callbackServer.close();
|
||||
} else {
|
||||
params.progress.update("Waiting for redirect URL…");
|
||||
const input = await params.prompt("Paste the redirect URL: ");
|
||||
const parsed = parseCallbackInput(input);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
code = parsed.code;
|
||||
returnedState = parsed.state;
|
||||
}
|
||||
|
||||
if (!code) throw new Error("Missing OAuth code");
|
||||
if (returnedState !== state) {
|
||||
throw new Error("OAuth state mismatch. Please try again.");
|
||||
}
|
||||
|
||||
params.progress.update("Exchanging code for tokens…");
|
||||
const tokens = await exchangeCode({ code, verifier });
|
||||
const email = await fetchUserEmail(tokens.access);
|
||||
const projectId = await fetchProjectId(tokens.access);
|
||||
|
||||
params.progress.stop("Antigravity OAuth complete");
|
||||
return { ...tokens, email, projectId };
|
||||
}
|
||||
|
||||
const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["antigravity"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
|
||||
try {
|
||||
const result = await loginAntigravity({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
note: ctx.prompter.note,
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
const profileId = `google-antigravity:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "google-antigravity",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"Antigravity uses Google Cloud project quotas.",
|
||||
"Enable Gemini for Google Cloud on your project if requests fail.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default antigravityPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Gemini CLI Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-gemini-cli-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||
```
|
||||
|
||||
## Env vars
|
||||
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||
@@ -0,0 +1,88 @@
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
const PROVIDER_LABEL = "Gemini CLI OAuth";
|
||||
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
|
||||
const ENV_VARS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
|
||||
const geminiCliPlugin = {
|
||||
id: "google-gemini-cli-auth",
|
||||
name: "Google Gemini CLI Auth",
|
||||
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["gemini-cli"],
|
||||
envVars: ENV_VARS,
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
||||
try {
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
log: (msg) => ctx.runtime.log(msg),
|
||||
note: ctx.prompter.note,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
spin.stop("Gemini CLI OAuth complete");
|
||||
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Gemini CLI OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
|
||||
"OAuth help",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default geminiCliPlugin;
|
||||
@@ -0,0 +1,496 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
];
|
||||
|
||||
const TIER_FREE = "free-tier";
|
||||
const TIER_LEGACY = "legacy-tier";
|
||||
const TIER_STANDARD = "standard-tier";
|
||||
|
||||
export type GeminiCliOAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type GeminiCliOAuthContext = {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
};
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
||||
);
|
||||
}
|
||||
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function buildAuthUrl(challenge: string, verifier: string): string {
|
||||
const { clientId } = resolveOAuthClientConfig();
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
return { code, state };
|
||||
} catch {
|
||||
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8085;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = "/oauth2callback";
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>Gemini CLI OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to Clawdbot.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
|
||||
const { clientId, clientSecret } = resolveOAuthClientConfig();
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
if (clientSecret) {
|
||||
body.set("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Please try again.");
|
||||
}
|
||||
|
||||
const email = await getUserEmail(data.access_token);
|
||||
const projectId = await discoverProject(data.access_token);
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/clawdbot",
|
||||
};
|
||||
|
||||
const loadBody = {
|
||||
cloudaicompanionProject: envProject,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: envProject,
|
||||
},
|
||||
};
|
||||
|
||||
let data: {
|
||||
currentTier?: { id?: string };
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(loadBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null);
|
||||
if (isVpcScAffected(errorPayload)) {
|
||||
data = { currentTier: { id: TIER_STANDARD } };
|
||||
} else {
|
||||
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} else {
|
||||
data = (await response.json()) as typeof data;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error("loadCodeAssist failed");
|
||||
}
|
||||
|
||||
if (data.currentTier) {
|
||||
const project = data.cloudaicompanionProject;
|
||||
if (typeof project === "string" && project) return project;
|
||||
if (typeof project === "object" && project?.id) return project.id;
|
||||
if (envProject) return envProject;
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const tier = getDefaultTier(data.allowedTiers);
|
||||
const tierId = tier?.id || TIER_FREE;
|
||||
if (tierId !== TIER_FREE && !envProject) {
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const onboardBody: Record<string, unknown> = {
|
||||
tierId,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
};
|
||||
if (tierId !== TIER_FREE && envProject) {
|
||||
onboardBody.cloudaicompanionProject = envProject;
|
||||
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
});
|
||||
|
||||
if (!onboardResponse.ok) {
|
||||
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
|
||||
}
|
||||
|
||||
let lro = (await onboardResponse.json()) as {
|
||||
done?: boolean;
|
||||
name?: string;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
|
||||
if (!lro.done && lro.name) {
|
||||
lro = await pollOperation(lro.name, headers);
|
||||
}
|
||||
|
||||
const projectId = lro.response?.cloudaicompanionProject?.id;
|
||||
if (projectId) return projectId;
|
||||
if (envProject) return envProject;
|
||||
|
||||
throw new Error(
|
||||
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
);
|
||||
}
|
||||
|
||||
function isVpcScAffected(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const details = (error as { details?: unknown[] }).details;
|
||||
if (!Array.isArray(details)) return false;
|
||||
return details.some(
|
||||
(item) =>
|
||||
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultTier(
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||
): { id?: string } | undefined {
|
||||
if (!allowedTiers?.length) return { id: TIER_LEGACY };
|
||||
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
||||
}
|
||||
|
||||
async function pollOperation(
|
||||
operationName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
done?: boolean;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
if (data.done) return data;
|
||||
}
|
||||
throw new Error("Operation polling timeout");
|
||||
}
|
||||
|
||||
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
|
||||
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
|
||||
await ctx.note(
|
||||
needsManual
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account for Gemini CLI access.",
|
||||
"The callback will be captured automatically on localhost:8085.",
|
||||
].join("\n"),
|
||||
"Gemini CLI OAuth",
|
||||
);
|
||||
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const authUrl = buildAuthUrl(challenge, verifier);
|
||||
|
||||
if (needsManual) {
|
||||
ctx.progress.update("OAuth URL ready");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
ctx.progress.update("Waiting for you to paste the callback URL...");
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
|
||||
ctx.progress.update("Complete sign-in in browser...");
|
||||
try {
|
||||
await ctx.openUrl(authUrl);
|
||||
} catch {
|
||||
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = await waitForLocalCallback({
|
||||
expectedState: verifier,
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
onProgress: (msg) => ctx.progress.update(msg),
|
||||
});
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return await exchangeCodeForTokens(code, verifier);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") ||
|
||||
err.message.includes("port") ||
|
||||
err.message.includes("listen"))
|
||||
) {
|
||||
ctx.progress.update("Local callback server failed. Switching to manual mode...");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "14.1.0",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createActionGate, readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelToolSend,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { handleMatrixAction } from "./tool-actions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelToolSend,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../../src/channels/plugins/config-helpers.js";
|
||||
import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js";
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
|
||||
import { applyAccountNameToChannelSection } from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
type ChannelPlugin,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
@@ -25,6 +26,11 @@ import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
} from "./directory-live.js";
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
@@ -147,8 +153,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
approveHint: formatPairingApproveHint("matrix"),
|
||||
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
|
||||
}),
|
||||
collectWarnings: ({ account }) => {
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
|
||||
@@ -234,6 +241,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
listPeersLive: async ({ cfg, query, limit }) =>
|
||||
listMatrixDirectoryPeersLive({ cfg, query, limit }),
|
||||
listGroupsLive: async ({ cfg, query, limit }) =>
|
||||
listMatrixDirectoryGroupsLive({ cfg, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
|
||||
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ChannelDirectoryEntry } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
type MatrixUserResult = {
|
||||
user_id?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
type MatrixUserDirectoryResponse = {
|
||||
results?: MatrixUserResult[];
|
||||
};
|
||||
|
||||
type MatrixJoinedRoomsResponse = {
|
||||
joined_rooms?: string[];
|
||||
};
|
||||
|
||||
type MatrixRoomNameState = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type MatrixAliasLookup = {
|
||||
room_id?: string;
|
||||
};
|
||||
|
||||
async function fetchMatrixJson<T>(params: {
|
||||
homeserver: string;
|
||||
path: string;
|
||||
accessToken: string;
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${params.homeserver}${params.path}`, {
|
||||
method: params.method ?? "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(params: {
|
||||
cfg: unknown;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) return [];
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/user_directory/search",
|
||||
method: "POST",
|
||||
body: {
|
||||
search_term: query,
|
||||
limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20,
|
||||
},
|
||||
});
|
||||
const results = res.results ?? [];
|
||||
return results
|
||||
.map((entry) => {
|
||||
const userId = entry.user_id?.trim();
|
||||
if (!userId) return null;
|
||||
return {
|
||||
kind: "user",
|
||||
id: userId,
|
||||
name: entry.display_name?.trim() || undefined,
|
||||
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
|
||||
raw: entry,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
})
|
||||
.filter(Boolean) as ChannelDirectoryEntry[];
|
||||
}
|
||||
|
||||
async function resolveMatrixRoomAlias(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
alias: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixAliasLookup>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
});
|
||||
return res.room_id?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatrixRoomName(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
roomId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixRoomNameState>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
});
|
||||
return res.name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryGroupsLive(params: {
|
||||
cfg: unknown;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) return [];
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||
|
||||
if (query.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||
if (!roomId) return [];
|
||||
return [
|
||||
{
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
name: query,
|
||||
handle: query,
|
||||
} satisfies ChannelDirectoryEntry,
|
||||
];
|
||||
}
|
||||
|
||||
if (query.startsWith("!")) {
|
||||
return [
|
||||
{
|
||||
kind: "group",
|
||||
id: query,
|
||||
name: query,
|
||||
} satisfies ChannelDirectoryEntry,
|
||||
];
|
||||
}
|
||||
|
||||
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/joined_rooms",
|
||||
});
|
||||
const rooms = joined.joined_rooms ?? [];
|
||||
const results: ChannelDirectoryEntry[] = [];
|
||||
|
||||
for (const roomId of rooms) {
|
||||
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
||||
if (!name) continue;
|
||||
if (!name.toLowerCase().includes(query)) continue;
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
name,
|
||||
handle: `#${name}`,
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelGroupContext } from "../../../src/channels/plugins/types.js";
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../../src/routing/session-key.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "../../../../src/config/config.js";
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
|
||||
Loaded 100 of 547 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user