Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504f2b0606 | ||
|
|
e10e81f579 |
No files matched your search
@@ -73,7 +73,6 @@
|
|||||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||||
- Environment variables: see `~/.profile`.
|
- 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.
|
- 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
|
## Troubleshooting
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||||
@@ -118,7 +117,6 @@
|
|||||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
- 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`.
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
- 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)
|
## NPM + 1Password (publish/verify)
|
||||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||||
|
|||||||
+12
-153
@@ -1,163 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
## 2026.1.16 (unreleased)
|
||||||
|
|
||||||
## 2026.1.18-3
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Exec: add host/security/ask routing for gateway + node exec.
|
|
||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
|
||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
|
||||||
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
|
|
||||||
|
|
||||||
## 2026.1.18-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
|
||||||
|
|
||||||
## 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: add the bundled BlueBubbles channel plugin (disabled by default).
|
|
||||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
|
||||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
|
||||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
|
||||||
|
|
||||||
## 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 FTS + embedding cache state in `clawdbot memory status`.
|
|
||||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
|
||||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
|
||||||
- Tools: centralize plugin tool policy helpers.
|
|
||||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
|
||||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### 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
|
### Highlights
|
||||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
|
||||||
- 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.
|
||||||
- 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.
|
||||||
- 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 linking. (#1033) — thanks @thewilloftheshadow.
|
||||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **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:** 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:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
- **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:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
- **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 plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||||
|
|
||||||
### Changes
|
### 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: improve `web_fetch` extraction using Readability (with fallback).
|
||||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
- 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: 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: 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: 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 tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||||
- Tools: add `process submit` helper to send CR 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: 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.
|
- 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.
|
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||||
- UI: allow deleting sessions from the Control UI.
|
- 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.
|
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
- 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.
|
- 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: refresh live directory cache results when resolving targets.
|
||||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
- 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.
|
- 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.
|
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||||
@@ -166,50 +44,31 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||||
|
|
||||||
### Fixes
|
### 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: 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.
|
- 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: 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: 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: 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: 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.
|
- 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.
|
- 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: `/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)
|
- 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: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||||
- Sessions: repair orphaned user turns before embedded prompts.
|
- 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.
|
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
- 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.
|
||||||
- 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.
|
- 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 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: 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.
|
- 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: 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: 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.
|
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
- 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.
|
- 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.
|
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||||
- iMessage: avoid RPC restart loops.
|
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||||
@@ -217,7 +76,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Models: align ZAI thinking toggles.
|
- Models: align ZAI thinking toggles.
|
||||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||||
- Skills: fix skills watcher ignored list typing (tsc).
|
|
||||||
|
|
||||||
## 2026.1.15
|
## 2026.1.15
|
||||||
|
|
||||||
@@ -258,6 +116,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
|||||||
- `/compact` — compact session context (summary)
|
- `/compact` — compact session context (summary)
|
||||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||||
- `/verbose on|off`
|
- `/verbose on|off`
|
||||||
- `/usage off|tokens|full` — per-response usage footer
|
- `/cost on|off` — append per-response token/cost usage lines
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
- `/restart` — restart the gateway (owner-only in groups)
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
- `/activation mention|always` — group activation toggle (groups only)
|
||||||
|
|
||||||
@@ -478,21 +478,20 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
<a href="https://github.com/search?q=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/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/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||||
<a href="https://github.com/search?q=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/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/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
+16
-16
@@ -2,22 +2,6 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
|
||||||
<title>2026.1.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>
|
<item>
|
||||||
<title>2026.1.15</title>
|
<title>2026.1.15</title>
|
||||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||||
@@ -271,5 +255,21 @@
|
|||||||
]]></description>
|
]]></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=="/>
|
<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>
|
||||||
|
<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>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -170,15 +170,8 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var execApprovalMode: ExecApprovalQuickMode {
|
var systemRunPolicy: SystemRunPolicy {
|
||||||
didSet {
|
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
||||||
self.ifNotPreview {
|
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
|
||||||
defaults.security = self.execApprovalMode.security
|
|
||||||
defaults.ask = self.execApprovalMode.ask
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||||
@@ -281,8 +274,7 @@ final class AppState {
|
|||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
let execDefaults = ExecApprovalsStore.resolveDefaults()
|
self.systemRunPolicy = SystemRunPolicy.load()
|
||||||
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
|
|
||||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||||
let prefix = Self.installPrefix()
|
let prefix = Self.installPrefix()
|
||||||
await statusHandler("Installing clawdbot CLI…")
|
await statusHandler("Installing clawdbot CLI…")
|
||||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
|
|||||||
let path: ConfigPath
|
let path: ConfigPath
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
self.renderNode(self.schema, path: self.path)
|
self.renderNode(schema, path: path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||||
let storedValue = self.store.configValue(at: path)
|
let storedValue = store.configValue(at: path)
|
||||||
let value = storedValue ?? schema.explicitDefault
|
let value = storedValue ?? schema.explicitDefault
|
||||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
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 {
|
if nonNull.count == 1, let only = nonNull.first {
|
||||||
return self.renderNode(only, path: path)
|
return self.renderNode(only, path: path)
|
||||||
}
|
}
|
||||||
let literals = nonNull.compactMap(\.literalValue)
|
let literals = nonNull.compactMap { $0.literalValue }
|
||||||
if !literals.isEmpty, literals.count == nonNull.count {
|
if !literals.isEmpty, literals.count == nonNull.count {
|
||||||
return AnyView(
|
return AnyView(
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -31,20 +31,15 @@ struct ConfigSchemaForm: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Picker(
|
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
|
||||||
"",
|
|
||||||
selection: self.enumBinding(
|
|
||||||
path,
|
|
||||||
options: literals,
|
|
||||||
defaultValue: schema.explicitDefault))
|
|
||||||
{
|
|
||||||
Text("Select…").tag(-1)
|
Text("Select…").tag(-1)
|
||||||
ForEach(literals.indices, id: \ .self) { index in
|
ForEach(literals.indices, id: \ .self) { index in
|
||||||
Text(String(describing: literals[index])).tag(index)
|
Text(String(describing: literals[index])).tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +71,8 @@ struct ConfigSchemaForm: View {
|
|||||||
if schema.allowsAdditionalProperties {
|
if schema.allowsAdditionalProperties {
|
||||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
case "array":
|
case "array":
|
||||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||||
case "boolean":
|
case "boolean":
|
||||||
@@ -84,7 +80,8 @@ struct ConfigSchemaForm: View {
|
|||||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||||
if let label { Text(label) } else { Text("Enabled") }
|
if let label { Text(label) } else { Text("Enabled") }
|
||||||
}
|
}
|
||||||
.help(help ?? ""))
|
.help(help ?? "")
|
||||||
|
)
|
||||||
case "number", "integer":
|
case "number", "integer":
|
||||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||||
case "string":
|
case "string":
|
||||||
@@ -96,7 +93,8 @@ struct ConfigSchemaForm: View {
|
|||||||
Text("Unsupported field type.")
|
Text("Unsupported field type.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +155,9 @@ struct ConfigSchemaForm: View {
|
|||||||
text: self.numberBinding(
|
text: self.numberBinding(
|
||||||
path,
|
path,
|
||||||
isInteger: schema.schemaType == "integer",
|
isInteger: schema.schemaType == "integer",
|
||||||
defaultValue: defaultValue))
|
defaultValue: defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Button("Remove") {
|
Button("Remove") {
|
||||||
var next = items
|
var next = items
|
||||||
next.remove(at: index)
|
next.remove(at: index)
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
|
|||||||
} else {
|
} else {
|
||||||
next.append("")
|
next.append("")
|
||||||
}
|
}
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Button("Remove") {
|
Button("Remove") {
|
||||||
var next = dict
|
var next = dict
|
||||||
next.removeValue(forKey: key)
|
next.removeValue(forKey: key)
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
|
|||||||
key = "new-\(index)"
|
key = "new-\(index)"
|
||||||
}
|
}
|
||||||
next[key] = additionalSchema.defaultValue
|
next[key] = additionalSchema.defaultValue
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -270,8 +270,9 @@ struct ConfigSchemaForm: View {
|
|||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
||||||
@@ -281,15 +282,16 @@ struct ConfigSchemaForm: View {
|
|||||||
return defaultValue ?? false
|
return defaultValue ?? false
|
||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
self.store.updateConfigValue(path: path, value: newValue)
|
store.updateConfigValue(path: path, value: newValue)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func numberBinding(
|
private func numberBinding(
|
||||||
_ path: ConfigPath,
|
_ path: ConfigPath,
|
||||||
isInteger: Bool,
|
isInteger: Bool,
|
||||||
defaultValue: Double?) -> Binding<String>
|
defaultValue: Double?
|
||||||
{
|
) -> Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||||
@@ -299,21 +301,22 @@ struct ConfigSchemaForm: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
self.store.updateConfigValue(path: path, value: nil)
|
store.updateConfigValue(path: path, value: nil)
|
||||||
} else if let value = Double(trimmed) {
|
} else if let value = Double(trimmed) {
|
||||||
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enumBinding(
|
private func enumBinding(
|
||||||
_ path: ConfigPath,
|
_ path: ConfigPath,
|
||||||
options: [Any],
|
options: [Any],
|
||||||
defaultValue: Any?) -> Binding<Int>
|
defaultValue: Any?
|
||||||
{
|
) -> Binding<Int> {
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
let value = self.store.configValue(at: path) ?? defaultValue
|
let value = store.configValue(at: path) ?? defaultValue
|
||||||
guard let value else { return -1 }
|
guard let value else { return -1 }
|
||||||
return options.firstIndex { option in
|
return options.firstIndex { option in
|
||||||
String(describing: option) == String(describing: value)
|
String(describing: option) == String(describing: value)
|
||||||
@@ -321,11 +324,12 @@ struct ConfigSchemaForm: View {
|
|||||||
},
|
},
|
||||||
set: { index in
|
set: { index in
|
||||||
guard index >= 0, index < options.count else {
|
guard index >= 0, index < options.count else {
|
||||||
self.store.updateConfigValue(path: path, value: nil)
|
store.updateConfigValue(path: path, value: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.store.updateConfigValue(path: path, value: options[index])
|
store.updateConfigValue(path: path, value: options[index])
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||||
@@ -335,13 +339,14 @@ struct ConfigSchemaForm: View {
|
|||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
guard trimmed != key else { return }
|
guard trimmed != key else { return }
|
||||||
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
||||||
guard current[trimmed] == nil else { return }
|
guard current[trimmed] == nil else { return }
|
||||||
var next = current
|
var next = current
|
||||||
next[trimmed] = current[key]
|
next[trimmed] = current[key]
|
||||||
next.removeValue(forKey: key)
|
next.removeValue(forKey: key)
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
store.updateConfigValue(path: path, value: next)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,10 +355,10 @@ struct ChannelConfigForm: View {
|
|||||||
let channelId: String
|
let channelId: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if self.store.configSchemaLoading {
|
if store.configSchemaLoading {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
||||||
} else {
|
} else {
|
||||||
Text("Schema unavailable for this channel.")
|
Text("Schema unavailable for this channel.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -434,25 +434,25 @@ extension ChannelsSettings {
|
|||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||||
switch id {
|
switch id {
|
||||||
case "whatsapp": "WhatsApp Web"
|
case "whatsapp": return "WhatsApp Web"
|
||||||
case "telegram": "Telegram Bot"
|
case "telegram": return "Telegram Bot"
|
||||||
case "discord": "Discord Bot"
|
case "discord": return "Discord Bot"
|
||||||
case "slack": "Slack Bot"
|
case "slack": return "Slack Bot"
|
||||||
case "signal": "Signal REST"
|
case "signal": return "Signal REST"
|
||||||
case "imessage": "iMessage"
|
case "imessage": return "iMessage"
|
||||||
default: self.resolveChannelTitle(id)
|
default: return self.resolveChannelTitle(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
switch id {
|
switch id {
|
||||||
case "whatsapp": "message"
|
case "whatsapp": return "message"
|
||||||
case "telegram": "paperplane"
|
case "telegram": return "paperplane"
|
||||||
case "discord": "bubble.left.and.bubble.right"
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
case "slack": "number"
|
case "slack": return "number"
|
||||||
case "signal": "antenna.radiowaves.left.and.right"
|
case "signal": return "antenna.radiowaves.left.and.right"
|
||||||
case "imessage": "message.fill"
|
case "imessage": return "message.fill"
|
||||||
default: "message"
|
default: return "message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ extension ChannelsStore {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
guard path.count >= 2 else { return nil }
|
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())
|
let fallbackPath = Array(path.dropFirst())
|
||||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||||
}
|
}
|
||||||
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
|||||||
var current: Any? = root
|
var current: Any? = root
|
||||||
for segment in path {
|
for segment in path {
|
||||||
switch segment {
|
switch segment {
|
||||||
case let .key(key):
|
case .key(let key):
|
||||||
guard let dict = current as? [String: Any] else { return nil }
|
guard let dict = current as? [String: Any] else { return nil }
|
||||||
current = dict[key]
|
current = dict[key]
|
||||||
case let .index(index):
|
case .index(let index):
|
||||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||||
current = array[index]
|
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?) {
|
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||||
guard let segment = path.first else { return }
|
guard let segment = path.first else { return }
|
||||||
switch segment {
|
switch segment {
|
||||||
case let .key(key):
|
case .key(let key):
|
||||||
var dict = root as? [String: Any] ?? [:]
|
var dict = root as? [String: Any] ?? [:]
|
||||||
if path.count == 1 {
|
if path.count == 1 {
|
||||||
if let value {
|
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)
|
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||||
dict[key] = child
|
dict[key] = child
|
||||||
root = dict
|
root = dict
|
||||||
case let .index(index):
|
case .index(let index):
|
||||||
var array = root as? [Any] ?? []
|
var array = root as? [Any] ?? []
|
||||||
if index >= array.count {
|
if index >= array.count {
|
||||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||||
|
|||||||
@@ -214,10 +214,9 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
configRoot: [String: Any]? = nil,
|
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
let settings = self.connectionSettings(defaults: defaults)
|
||||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
@@ -265,14 +264,12 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
configRoot: [String: Any]? = nil,
|
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
self.clawdbotNodeCommand(
|
self.clawdbotNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
configRoot: configRoot,
|
|
||||||
searchPaths: searchPaths)
|
searchPaths: searchPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +384,8 @@ enum CommandResolver {
|
|||||||
let cliPath: String
|
let cliPath: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings(
|
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||||
defaults: UserDefaults = .standard,
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
|
||||||
{
|
|
||||||
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
|
||||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
|
|||||||
for segment in path {
|
for segment in path {
|
||||||
guard let node = current else { return nil }
|
guard let node = current else { return nil }
|
||||||
switch segment {
|
switch segment {
|
||||||
case let .key(key):
|
case .key(let key):
|
||||||
if node.schemaType == "object" {
|
if node.schemaType == "object" {
|
||||||
if let next = node.properties[key] {
|
if let next = node.properties[key] {
|
||||||
current = next
|
current = next
|
||||||
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
|
|||||||
var match = true
|
var match = true
|
||||||
for (index, seg) in segments.enumerated() {
|
for (index, seg) in segments.enumerated() {
|
||||||
let hintSegment = hintSegments[index]
|
let hintSegment = hintSegments[index]
|
||||||
if hintSegment != "*", hintSegment != seg {
|
if hintSegment != "*" && hintSegment != seg {
|
||||||
match = false
|
match = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
|||||||
func pathKey(_ path: ConfigPath) -> String {
|
func pathKey(_ path: ConfigPath) -> String {
|
||||||
path.compactMap { segment -> String? in
|
path.compactMap { segment -> String? in
|
||||||
switch segment {
|
switch segment {
|
||||||
case let .key(key): return key
|
case .key(let key): return key
|
||||||
case .index: return nil
|
case .index: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
if self.store.configDirty && !self.isNixMode {
|
||||||
Text("Unsaved changes")
|
Text("Unsaved changes")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -1,566 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import Security
|
|
||||||
|
|
||||||
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
|
||||||
case deny
|
|
||||||
case allowlist
|
|
||||||
case full
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .deny: "Deny"
|
|
||||||
case .allowlist: "Allowlist"
|
|
||||||
case .full: "Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
|
||||||
case deny
|
|
||||||
case ask
|
|
||||||
case allow
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .deny: "Deny"
|
|
||||||
case .ask: "Always Ask"
|
|
||||||
case .allow: "Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var security: ExecSecurity {
|
|
||||||
switch self {
|
|
||||||
case .deny: .deny
|
|
||||||
case .ask: .allowlist
|
|
||||||
case .allow: .full
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ask: ExecAsk {
|
|
||||||
switch self {
|
|
||||||
case .deny: .off
|
|
||||||
case .ask: .onMiss
|
|
||||||
case .allow: .off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
|
|
||||||
switch security {
|
|
||||||
case .deny:
|
|
||||||
return .deny
|
|
||||||
case .full:
|
|
||||||
return .allow
|
|
||||||
case .allowlist:
|
|
||||||
return .ask
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
|
||||||
case off
|
|
||||||
case onMiss = "on-miss"
|
|
||||||
case always
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .off: "Never Ask"
|
|
||||||
case .onMiss: "Ask on Allowlist Miss"
|
|
||||||
case .always: "Always Ask"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalDecision: String, Codable, Sendable {
|
|
||||||
case allowOnce = "allow-once"
|
|
||||||
case allowAlways = "allow-always"
|
|
||||||
case deny
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable {
|
|
||||||
var pattern: String
|
|
||||||
var lastUsedAt: Double? = nil
|
|
||||||
var lastUsedCommand: String? = nil
|
|
||||||
var lastResolvedPath: String? = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsDefaults: Codable {
|
|
||||||
var security: ExecSecurity?
|
|
||||||
var ask: ExecAsk?
|
|
||||||
var askFallback: ExecSecurity?
|
|
||||||
var autoAllowSkills: Bool?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsAgent: Codable {
|
|
||||||
var security: ExecSecurity?
|
|
||||||
var ask: ExecAsk?
|
|
||||||
var askFallback: ExecSecurity?
|
|
||||||
var autoAllowSkills: Bool?
|
|
||||||
var allowlist: [ExecAllowlistEntry]?
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsSocketConfig: Codable {
|
|
||||||
var path: String?
|
|
||||||
var token: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsFile: Codable {
|
|
||||||
var version: Int
|
|
||||||
var socket: ExecApprovalsSocketConfig?
|
|
||||||
var defaults: ExecApprovalsDefaults?
|
|
||||||
var agents: [String: ExecApprovalsAgent]?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsResolved {
|
|
||||||
let url: URL
|
|
||||||
let socketPath: String
|
|
||||||
let token: String
|
|
||||||
let defaults: ExecApprovalsResolvedDefaults
|
|
||||||
let agent: ExecApprovalsResolvedDefaults
|
|
||||||
let allowlist: [ExecAllowlistEntry]
|
|
||||||
var file: ExecApprovalsFile
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsResolvedDefaults {
|
|
||||||
var security: ExecSecurity
|
|
||||||
var ask: ExecAsk
|
|
||||||
var askFallback: ExecSecurity
|
|
||||||
var autoAllowSkills: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalsStore {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
|
||||||
private static let defaultSecurity: ExecSecurity = .deny
|
|
||||||
private static let defaultAsk: ExecAsk = .onMiss
|
|
||||||
private static let defaultAskFallback: ExecSecurity = .deny
|
|
||||||
private static let defaultAutoAllowSkills = false
|
|
||||||
|
|
||||||
static func fileURL() -> URL {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func socketPath() -> String {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
|
||||||
let url = self.fileURL()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
|
||||||
if decoded.version != 1 {
|
|
||||||
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
|
||||||
}
|
|
||||||
return decoded
|
|
||||||
} catch {
|
|
||||||
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFile(_ file: ExecApprovalsFile) {
|
|
||||||
do {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
let data = try encoder.encode(file)
|
|
||||||
let url = self.fileURL()
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ensureFile() -> ExecApprovalsFile {
|
|
||||||
var file = self.loadFile()
|
|
||||||
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
|
||||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if path.isEmpty {
|
|
||||||
file.socket?.path = self.socketPath()
|
|
||||||
}
|
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if token.isEmpty {
|
|
||||||
file.socket?.token = self.generateToken()
|
|
||||||
}
|
|
||||||
if file.agents == nil { file.agents = [:] }
|
|
||||||
self.saveFile(file)
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
|
||||||
var file = self.ensureFile()
|
|
||||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
|
||||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
|
||||||
security: defaults.security ?? self.defaultSecurity,
|
|
||||||
ask: defaults.ask ?? self.defaultAsk,
|
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
|
||||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
||||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
: "default"
|
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
|
||||||
security: agentEntry.security ?? resolvedDefaults.security,
|
|
||||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
|
||||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
|
||||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
|
||||||
let allowlist = (agentEntry.allowlist ?? [])
|
|
||||||
.map { entry in
|
|
||||||
ExecAllowlistEntry(
|
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
lastUsedAt: entry.lastUsedAt,
|
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
|
||||||
lastResolvedPath: entry.lastResolvedPath)
|
|
||||||
}
|
|
||||||
.filter { !$0.pattern.isEmpty }
|
|
||||||
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
|
||||||
let token = file.socket?.token ?? ""
|
|
||||||
return ExecApprovalsResolved(
|
|
||||||
url: self.fileURL(),
|
|
||||||
socketPath: socketPath,
|
|
||||||
token: token,
|
|
||||||
defaults: resolvedDefaults,
|
|
||||||
agent: resolvedAgent,
|
|
||||||
allowlist: allowlist,
|
|
||||||
file: file)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
|
||||||
let file = self.ensureFile()
|
|
||||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
|
||||||
return ExecApprovalsResolvedDefaults(
|
|
||||||
security: defaults.security ?? self.defaultSecurity,
|
|
||||||
ask: defaults.ask ?? self.defaultAsk,
|
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
|
||||||
self.updateFile { file in
|
|
||||||
file.defaults = defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
|
||||||
self.updateFile { file in
|
|
||||||
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
|
||||||
mutate(&defaults)
|
|
||||||
file.defaults = defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
|
||||||
self.updateFile { file in
|
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
let key = self.agentKey(agentId)
|
|
||||||
if agent.isEmpty {
|
|
||||||
agents.removeValue(forKey: key)
|
|
||||||
} else {
|
|
||||||
agents[key] = agent
|
|
||||||
}
|
|
||||||
file.agents = agents.isEmpty ? nil : agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
self.updateFile { file in
|
|
||||||
let key = self.agentKey(agentId)
|
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
|
||||||
var allowlist = entry.allowlist ?? []
|
|
||||||
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
|
||||||
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
|
||||||
entry.allowlist = allowlist
|
|
||||||
agents[key] = entry
|
|
||||||
file.agents = agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func recordAllowlistUse(
|
|
||||||
agentId: String?,
|
|
||||||
pattern: String,
|
|
||||||
command: String,
|
|
||||||
resolvedPath: String?)
|
|
||||||
{
|
|
||||||
self.updateFile { file in
|
|
||||||
let key = self.agentKey(agentId)
|
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
|
||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
|
||||||
guard item.pattern == pattern else { return item }
|
|
||||||
return ExecAllowlistEntry(
|
|
||||||
pattern: item.pattern,
|
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
|
||||||
lastUsedCommand: command,
|
|
||||||
lastResolvedPath: resolvedPath)
|
|
||||||
}
|
|
||||||
entry.allowlist = allowlist
|
|
||||||
agents[key] = entry
|
|
||||||
file.agents = agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
|
||||||
self.updateFile { file in
|
|
||||||
let key = self.agentKey(agentId)
|
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { item in
|
|
||||||
ExecAllowlistEntry(
|
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
lastUsedAt: item.lastUsedAt,
|
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
|
||||||
lastResolvedPath: item.lastResolvedPath)
|
|
||||||
}
|
|
||||||
.filter { !$0.pattern.isEmpty }
|
|
||||||
entry.allowlist = cleaned
|
|
||||||
agents[key] = entry
|
|
||||||
file.agents = agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
|
||||||
self.updateFile { file in
|
|
||||||
let key = self.agentKey(agentId)
|
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
|
||||||
mutate(&entry)
|
|
||||||
if entry.isEmpty {
|
|
||||||
agents.removeValue(forKey: key)
|
|
||||||
} else {
|
|
||||||
agents[key] = entry
|
|
||||||
}
|
|
||||||
file.agents = agents.isEmpty ? nil : agents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
|
||||||
var file = self.ensureFile()
|
|
||||||
mutate(&file)
|
|
||||||
self.saveFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func generateToken() -> String {
|
|
||||||
var bytes = [UInt8](repeating: 0, count: 24)
|
|
||||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
||||||
if status == errSecSuccess {
|
|
||||||
return Data(bytes)
|
|
||||||
.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
return UUID().uuidString
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func expandPath(_ raw: String) -> String {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed == "~" {
|
|
||||||
return FileManager.default.homeDirectoryForCurrentUser.path
|
|
||||||
}
|
|
||||||
if trimmed.hasPrefix("~/") {
|
|
||||||
let suffix = trimmed.dropFirst(2)
|
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(String(suffix)).path
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func agentKey(_ agentId: String?) -> String {
|
|
||||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? "default" : trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecCommandResolution: Sendable {
|
|
||||||
let rawExecutable: String
|
|
||||||
let resolvedPath: String?
|
|
||||||
let executableName: String
|
|
||||||
let cwd: String?
|
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
|
||||||
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("/") || 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
|
|
||||||
}
|
|
||||||
let searchPaths = self.searchPaths(from: env)
|
|
||||||
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
|
||||||
}()
|
|
||||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
|
||||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
|
||||||
let raw = env?["PATH"]
|
|
||||||
if let raw, !raw.isEmpty {
|
|
||||||
return raw.split(separator: ":").map(String.init)
|
|
||||||
}
|
|
||||||
return CommandResolver.preferredPaths()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecCommandFormatter {
|
|
||||||
static func displayString(for argv: [String]) -> String {
|
|
||||||
argv.map { arg in
|
|
||||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "\"\"" }
|
|
||||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
|
||||||
if !needsQuotes { return trimmed }
|
|
||||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
return "\"\(escaped)\""
|
|
||||||
}.joined(separator: " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecAllowlistMatcher {
|
|
||||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
|
||||||
guard let resolution, !entries.isEmpty else { return nil }
|
|
||||||
let rawExecutable = resolution.rawExecutable
|
|
||||||
let resolvedPath = resolution.resolvedPath
|
|
||||||
let executableName = resolution.executableName
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if pattern.isEmpty { continue }
|
|
||||||
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
|
||||||
if hasPath {
|
|
||||||
let target = resolvedPath ?? rawExecutable
|
|
||||||
if self.matches(pattern: pattern, target: target) { return entry }
|
|
||||||
} else if self.matches(pattern: pattern, target: executableName) {
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func matches(pattern: String, target: String) -> Bool {
|
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return false }
|
|
||||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
|
||||||
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
|
||||||
let normalizedTarget = self.normalizeMatchTarget(target)
|
|
||||||
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
|
||||||
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
|
||||||
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeMatchTarget(_ value: String) -> String {
|
|
||||||
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
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, options: [.caseInsensitive])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecEventPayload: Codable, Sendable {
|
|
||||||
var sessionKey: String
|
|
||||||
var runId: String
|
|
||||||
var host: String
|
|
||||||
var command: String?
|
|
||||||
var exitCode: Int?
|
|
||||||
var timedOut: Bool?
|
|
||||||
var success: Bool?
|
|
||||||
var output: String?
|
|
||||||
var reason: String?
|
|
||||||
|
|
||||||
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return nil }
|
|
||||||
if trimmed.count <= maxChars { return trimmed }
|
|
||||||
let suffix = trimmed.suffix(maxChars)
|
|
||||||
return "… (truncated) \(suffix)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import ClawdbotKit
|
|
||||||
import Darwin
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
struct ExecApprovalPromptRequest: Codable, Sendable {
|
|
||||||
var command: String
|
|
||||||
var cwd: String?
|
|
||||||
var host: String?
|
|
||||||
var security: String?
|
|
||||||
var ask: String?
|
|
||||||
var agentId: String?
|
|
||||||
var resolvedPath: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ExecApprovalSocketRequest: Codable {
|
|
||||||
var type: String
|
|
||||||
var token: String
|
|
||||||
var id: String
|
|
||||||
var request: ExecApprovalPromptRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ExecApprovalSocketDecision: Codable {
|
|
||||||
var type: String
|
|
||||||
var id: String
|
|
||||||
var decision: ExecApprovalDecision
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalsSocketClient {
|
|
||||||
private struct TimeoutError: LocalizedError {
|
|
||||||
var message: String
|
|
||||||
var errorDescription: String? { message }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func requestDecision(
|
|
||||||
socketPath: String,
|
|
||||||
token: String,
|
|
||||||
request: ExecApprovalPromptRequest,
|
|
||||||
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
|
|
||||||
{
|
|
||||||
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
|
|
||||||
do {
|
|
||||||
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
|
|
||||||
TimeoutError(message: "exec approvals socket timeout")
|
|
||||||
}, operation: {
|
|
||||||
try await Task.detached {
|
|
||||||
try self.requestDecisionSync(
|
|
||||||
socketPath: trimmedPath,
|
|
||||||
token: trimmedToken,
|
|
||||||
request: request)
|
|
||||||
}.value
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func requestDecisionSync(
|
|
||||||
socketPath: String,
|
|
||||||
token: String,
|
|
||||||
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
|
|
||||||
{
|
|
||||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
||||||
guard fd >= 0 else {
|
|
||||||
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "socket create failed",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
var addr = sockaddr_un()
|
|
||||||
addr.sun_family = sa_family_t(AF_UNIX)
|
|
||||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
|
||||||
if socketPath.utf8.count >= maxLen {
|
|
||||||
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "socket path too long",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
socketPath.withCString { cstr in
|
|
||||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
||||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
|
||||||
strncpy(raw, cstr, maxLen - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
|
||||||
let result = withUnsafePointer(to: &addr) { ptr in
|
|
||||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
|
||||||
connect(fd, rebound, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if result != 0 {
|
|
||||||
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "socket connect failed",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
|
||||||
|
|
||||||
let message = ExecApprovalSocketRequest(
|
|
||||||
type: "request",
|
|
||||||
token: token,
|
|
||||||
id: UUID().uuidString,
|
|
||||||
request: request)
|
|
||||||
let data = try JSONEncoder().encode(message)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
|
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
|
||||||
let lineData = line.data(using: .utf8)
|
|
||||||
else { return nil }
|
|
||||||
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
|
||||||
return response.decision
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
|
||||||
var buffer = Data()
|
|
||||||
while buffer.count < maxBytes {
|
|
||||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
|
||||||
if chunk.isEmpty { break }
|
|
||||||
buffer.append(chunk)
|
|
||||||
if buffer.contains(0x0A) { break }
|
|
||||||
}
|
|
||||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
|
||||||
guard !buffer.isEmpty else { return nil }
|
|
||||||
return String(data: buffer, encoding: .utf8)
|
|
||||||
}
|
|
||||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class ExecApprovalsPromptServer {
|
|
||||||
static let shared = ExecApprovalsPromptServer()
|
|
||||||
|
|
||||||
private var server: ExecApprovalsSocketServer?
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard self.server == nil else { return }
|
|
||||||
let approvals = ExecApprovalsStore.resolve(agentId: nil)
|
|
||||||
let server = ExecApprovalsSocketServer(
|
|
||||||
socketPath: approvals.socketPath,
|
|
||||||
token: approvals.token,
|
|
||||||
onPrompt: { request in
|
|
||||||
await ExecApprovalsPromptPresenter.prompt(request)
|
|
||||||
})
|
|
||||||
server.start()
|
|
||||||
self.server = server
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
self.server?.stop()
|
|
||||||
self.server = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ExecApprovalsPromptPresenter {
|
|
||||||
@MainActor
|
|
||||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.messageText = "Allow this command?"
|
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
|
||||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedAgent.isEmpty {
|
|
||||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
|
||||||
}
|
|
||||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedPath.isEmpty {
|
|
||||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
|
||||||
}
|
|
||||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedHost.isEmpty {
|
|
||||||
details += "\n\nHost:\n\(trimmedHost)"
|
|
||||||
}
|
|
||||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
|
||||||
details += "\n\nSecurity:\n\(security)"
|
|
||||||
}
|
|
||||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
|
||||||
details += "\nAsk mode:\n\(ask)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this machine."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
|
||||||
alert.addButton(withTitle: "Always Allow")
|
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
|
||||||
case .alertFirstButtonReturn:
|
|
||||||
return .allowOnce
|
|
||||||
case .alertSecondButtonReturn:
|
|
||||||
return .allowAlways
|
|
||||||
default:
|
|
||||||
return .deny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class ExecApprovalsSocketServer {
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
|
||||||
private let socketPath: String
|
|
||||||
private let token: String
|
|
||||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
|
||||||
private var socketFD: Int32 = -1
|
|
||||||
private var acceptTask: Task<Void, Never>?
|
|
||||||
private var isRunning = false
|
|
||||||
|
|
||||||
init(
|
|
||||||
socketPath: String,
|
|
||||||
token: String,
|
|
||||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
|
||||||
{
|
|
||||||
self.socketPath = socketPath
|
|
||||||
self.token = token
|
|
||||||
self.onPrompt = onPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !self.isRunning else { return }
|
|
||||||
self.isRunning = true
|
|
||||||
self.acceptTask = Task.detached { [weak self] in
|
|
||||||
await self?.runAcceptLoop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
self.isRunning = false
|
|
||||||
self.acceptTask?.cancel()
|
|
||||||
self.acceptTask = nil
|
|
||||||
if self.socketFD >= 0 {
|
|
||||||
close(self.socketFD)
|
|
||||||
self.socketFD = -1
|
|
||||||
}
|
|
||||||
if !self.socketPath.isEmpty {
|
|
||||||
unlink(self.socketPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runAcceptLoop() async {
|
|
||||||
let fd = self.openSocket()
|
|
||||||
guard fd >= 0 else {
|
|
||||||
self.isRunning = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.socketFD = fd
|
|
||||||
while self.isRunning {
|
|
||||||
var addr = sockaddr_un()
|
|
||||||
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
|
||||||
let client = withUnsafeMutablePointer(to: &addr) { ptr in
|
|
||||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
|
||||||
accept(fd, rebound, &len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if client < 0 {
|
|
||||||
if errno == EINTR { continue }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
await self?.handleClient(fd: client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openSocket() -> Int32 {
|
|
||||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
||||||
guard fd >= 0 else {
|
|
||||||
self.logger.error("exec approvals socket create failed")
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
unlink(self.socketPath)
|
|
||||||
var addr = sockaddr_un()
|
|
||||||
addr.sun_family = sa_family_t(AF_UNIX)
|
|
||||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
|
||||||
if self.socketPath.utf8.count >= maxLen {
|
|
||||||
self.logger.error("exec approvals socket path too long")
|
|
||||||
close(fd)
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
self.socketPath.withCString { cstr in
|
|
||||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
||||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
|
||||||
memset(raw, 0, maxLen)
|
|
||||||
strncpy(raw, cstr, maxLen - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
|
||||||
let result = withUnsafePointer(to: &addr) { ptr in
|
|
||||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
|
||||||
bind(fd, rebound, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if result != 0 {
|
|
||||||
self.logger.error("exec approvals socket bind failed")
|
|
||||||
close(fd)
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if listen(fd, 16) != 0 {
|
|
||||||
self.logger.error("exec approvals socket listen failed")
|
|
||||||
close(fd)
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
chmod(self.socketPath, 0o600)
|
|
||||||
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
|
||||||
return fd
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleClient(fd: Int32) async {
|
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
|
||||||
do {
|
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
|
||||||
let data = line.data(using: .utf8)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
|
||||||
guard request.type == "request", request.token == self.token else {
|
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let decision = await self.onPrompt(request.request)
|
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
|
|
||||||
let responseData = try JSONEncoder().encode(response)
|
|
||||||
var payload = responseData
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
|
||||||
var buffer = Data()
|
|
||||||
while buffer.count < maxBytes {
|
|
||||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
|
||||||
if chunk.isEmpty { break }
|
|
||||||
buffer.append(chunk)
|
|
||||||
if buffer.contains(0x0A) { break }
|
|
||||||
}
|
|
||||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
|
||||||
guard !buffer.isEmpty else { return nil }
|
|
||||||
return String(data: buffer, encoding: .utf8)
|
|
||||||
}
|
|
||||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,14 +25,8 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
|||||||
let major = Int(parts[0]),
|
let major = Int(parts[0]),
|
||||||
let minor = Int(parts[1])
|
let minor = Int(parts[1])
|
||||||
else { return nil }
|
else { return nil }
|
||||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
let patch = Int(parts[2]) ?? 0
|
||||||
let patchRaw = String(parts[2])
|
return Semver(major: major, minor: minor, patch: patch)
|
||||||
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 {
|
func compatible(with required: Semver) -> Bool {
|
||||||
@@ -84,13 +78,8 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func expectedGatewayVersion() -> Semver? {
|
static func expectedGatewayVersion() -> Semver? {
|
||||||
Semver.parse(self.expectedGatewayVersionString())
|
|
||||||
}
|
|
||||||
|
|
||||||
static func expectedGatewayVersionString() -> String? {
|
|
||||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
return Semver.parse(bundleVersion)
|
||||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||||
@@ -109,7 +98,6 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let expected = self.expectedGatewayVersion()
|
let expected = self.expectedGatewayVersion()
|
||||||
let expectedString = self.expectedGatewayVersionString()
|
|
||||||
|
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
@@ -120,8 +108,8 @@ enum GatewayEnvironment {
|
|||||||
kind: .missingNode,
|
kind: .missingNode,
|
||||||
nodeVersion: nil,
|
nodeVersion: nil,
|
||||||
gatewayVersion: nil,
|
gatewayVersion: nil,
|
||||||
requiredGateway: expectedString,
|
requiredGateway: expected?.description,
|
||||||
message: RuntimeLocator.describeFailure(err))
|
message: RuntimeLocator.describeFailure(err))
|
||||||
case let .success(runtime):
|
case let .success(runtime):
|
||||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||||
|
|
||||||
@@ -130,7 +118,7 @@ enum GatewayEnvironment {
|
|||||||
kind: .missingGateway,
|
kind: .missingGateway,
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: nil,
|
gatewayVersion: nil,
|
||||||
requiredGateway: expectedString,
|
requiredGateway: expected?.description,
|
||||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +126,13 @@ enum GatewayEnvironment {
|
|||||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||||
|
|
||||||
if let expected, let installed, !installed.compatible(with: expected) {
|
if let expected, let installed, !installed.compatible(with: expected) {
|
||||||
let expectedText = expectedString ?? expected.description
|
|
||||||
return GatewayEnvironmentStatus(
|
return GatewayEnvironmentStatus(
|
||||||
kind: .incompatible(found: installed.description, required: expectedText),
|
kind: .incompatible(found: installed.description, required: expected.description),
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: installed.description,
|
gatewayVersion: installed.description,
|
||||||
requiredGateway: expectedText,
|
requiredGateway: expected.description,
|
||||||
message: """
|
message: """
|
||||||
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||||
install or update the global package.
|
install or update the global package.
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
@@ -163,7 +150,7 @@ enum GatewayEnvironment {
|
|||||||
kind: .ok,
|
kind: .ok,
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: gatewayVersionText,
|
gatewayVersion: gatewayVersionText,
|
||||||
requiredGateway: expectedString,
|
requiredGateway: expected?.description,
|
||||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,18 +218,8 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
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 preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let target = version?.description ?? "latest"
|
||||||
let target: String
|
|
||||||
if let trimmed, !trimmed.isEmpty {
|
|
||||||
target = trimmed
|
|
||||||
} else {
|
|
||||||
target = "latest"
|
|
||||||
}
|
|
||||||
let npm = CommandResolver.findExecutable(named: "npm")
|
let npm = CommandResolver.findExecutable(named: "npm")
|
||||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||||
let bun = CommandResolver.findExecutable(named: "bun")
|
let bun = CommandResolver.findExecutable(named: "bun")
|
||||||
@@ -301,7 +278,8 @@ enum GatewayEnvironment {
|
|||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
do {
|
do {
|
||||||
let data = try process.runAndReadToEnd(from: pipe)
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
if elapsedMs > 500 {
|
if elapsedMs > 500 {
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
@@ -316,6 +294,7 @@ enum GatewayEnvironment {
|
|||||||
bin=\(binary, privacy: .public)
|
bin=\(binary, privacy: .public)
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||||
let raw = String(data: data, encoding: .utf8)?
|
let raw = String(data: data, encoding: .utf8)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return Semver.parse(raw)
|
return Semver.parse(raw)
|
||||||
|
|||||||
@@ -83,7 +83,27 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
SystemRunSettingsView()
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Node Run Commands")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Picker("", selection: self.$state.systemRunPolicy) {
|
||||||
|
ForEach(SystemRunPolicy.allCases) { policy in
|
||||||
|
Text(policy.title).tag(policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Text("""
|
||||||
|
Controls remote command execution on this Mac when it is paired as a node. \
|
||||||
|
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||||
|
"Never" disables `system.run`.
|
||||||
|
""")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
|
|||||||
let process = Process()
|
let process = Process()
|
||||||
process.launchPath = "/bin/launchctl"
|
process.launchPath = "/bin/launchctl"
|
||||||
process.arguments = args
|
process.arguments = args
|
||||||
let pipe = Pipe()
|
process.standardOutput = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardError = Pipe()
|
||||||
process.standardError = pipe
|
|
||||||
do {
|
do {
|
||||||
_ = try process.runAndReadToEnd(from: pipe)
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
return process.terminationStatus
|
return process.terminationStatus
|
||||||
} catch {
|
} catch {
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ enum Launchctl {
|
|||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
do {
|
do {
|
||||||
let data = try process.runAndReadToEnd(from: pipe)
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
return Result(status: process.terminationStatus, output: output)
|
return Result(status: process.terminationStatus, output: output)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
enum MacNodeConfigFile {
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
||||||
|
|
||||||
|
static func url() -> URL {
|
||||||
|
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDict() -> [String: Any] {
|
||||||
|
let url = self.url()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
self.logger.warning("mac node config JSON root invalid")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDict(_ dict: [String: Any]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
|
let url = self.url()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunPolicy() -> SystemRunPolicy? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = root["systemRun"] as? [String: Any]
|
||||||
|
let raw = systemRun?["policy"] as? String
|
||||||
|
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||||
|
var root = self.loadDict()
|
||||||
|
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||||
|
systemRun["policy"] = policy.rawValue
|
||||||
|
root["systemRun"] = systemRun
|
||||||
|
self.saveDict(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func systemRunAllowlist() -> [String]? {
|
||||||
|
let root = self.loadDict()
|
||||||
|
let systemRun = root["systemRun"] as? [String: Any]
|
||||||
|
return systemRun?["allowlist"] as? [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
var root = self.loadDict()
|
||||||
|
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||||
|
if cleaned.isEmpty {
|
||||||
|
systemRun.removeValue(forKey: "allowlist")
|
||||||
|
} else {
|
||||||
|
systemRun["allowlist"] = cleaned
|
||||||
|
}
|
||||||
|
if systemRun.isEmpty {
|
||||||
|
root.removeValue(forKey: "systemRun")
|
||||||
|
} else {
|
||||||
|
root["systemRun"] = systemRun
|
||||||
|
}
|
||||||
|
self.saveDict(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,7 +256,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
TerminationSignalWatcher.shared.start()
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
ExecApprovalsPromptServer.shared.start()
|
|
||||||
MacNodeModeCoordinator.shared.start()
|
MacNodeModeCoordinator.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
@@ -281,7 +280,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
ExecApprovalsPromptServer.shared.stop()
|
|
||||||
MacNodeModeCoordinator.shared.stop()
|
MacNodeModeCoordinator.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
TerminationSignalWatcher.shared.stop()
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
|
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { self.state.execApprovalMode },
|
get: { self.state.systemRunPolicy },
|
||||||
set: { self.state.execApprovalMode = $0 })
|
set: { self.state.systemRunPolicy = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -74,12 +74,12 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
Label("Allow Camera", systemImage: "camera")
|
||||||
}
|
}
|
||||||
Picker(selection: self.execApprovalModeBinding) {
|
Picker(selection: self.systemRunPolicyBinding) {
|
||||||
ForEach(ExecApprovalQuickMode.allCases) { mode in
|
ForEach(SystemRunPolicy.allCases) { policy in
|
||||||
Text(mode.title).tag(mode)
|
Text(policy.title).tag(policy)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Exec Approvals", systemImage: "terminal")
|
Label("Node Run Commands", systemImage: "terminal")
|
||||||
}
|
}
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
|
var lastSystemRunPolicy: SystemRunPolicy?
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@@ -59,6 +60,15 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let systemRunPolicy = SystemRunPolicy.load()
|
||||||
|
if lastSystemRunPolicy == nil {
|
||||||
|
lastSystemRunPolicy = systemRunPolicy
|
||||||
|
} else if lastSystemRunPolicy != systemRunPolicy {
|
||||||
|
lastSystemRunPolicy = systemRunPolicy
|
||||||
|
await self.session.disconnect()
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
@@ -79,13 +89,8 @@ final class MacNodeModeCoordinator {
|
|||||||
if let mainSessionKey {
|
if let mainSessionKey {
|
||||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
||||||
}
|
}
|
||||||
await self?.runtime.setEventSender { [weak self] event, payload in
|
|
||||||
guard let self else { return }
|
|
||||||
try? await self.session.sendEvent(event: event, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDisconnected: { [weak self] reason in
|
onDisconnected: { reason in
|
||||||
await self?.runtime.setEventSender(nil)
|
|
||||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
@@ -156,10 +161,13 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
ClawdbotSystemCommand.which.rawValue,
|
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if SystemRunPolicy.load() != .never {
|
||||||
|
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||||
|
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ actor MacNodeRuntime {
|
|||||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||||
private var mainSessionKey: String = "main"
|
private var mainSessionKey: String = "main"
|
||||||
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||||
@@ -24,10 +23,6 @@ actor MacNodeRuntime {
|
|||||||
self.mainSessionKey = trimmed
|
self.mainSessionKey = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
|
|
||||||
self.eventSender = sender
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||||
@@ -433,129 +428,41 @@ actor MacNodeRuntime {
|
|||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
switch Self.systemRunPolicy() {
|
||||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
case .never:
|
||||||
let security = approvals.agent.security
|
|
||||||
let ask = approvals.agent.ask
|
|
||||||
let askFallback = approvals.agent.askFallback
|
|
||||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
|
||||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
||||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
: self.mainSessionKey
|
|
||||||
let runId = UUID().uuidString
|
|
||||||
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
|
||||||
let allowlistMatch = security == .allowlist
|
|
||||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
|
||||||
: nil
|
|
||||||
let skillAllow: Bool
|
|
||||||
if autoAllowSkills, let name = resolution?.executableName {
|
|
||||||
let bins = await SkillBinsCache.shared.currentBins()
|
|
||||||
skillAllow = bins.contains(name)
|
|
||||||
} else {
|
|
||||||
skillAllow = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .deny {
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
reason: "security=deny"))
|
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
message: "SYSTEM_RUN_DISABLED: policy=never")
|
||||||
}
|
case .always:
|
||||||
|
break
|
||||||
let requiresAsk: Bool = {
|
case .ask:
|
||||||
if ask == .always { return true }
|
if !wasAllowlisted {
|
||||||
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
let services = await self.mainActorServices()
|
||||||
return false
|
let decision = await services.confirmSystemRun(
|
||||||
}()
|
command: SystemRunAllowlist.displayString(for: command),
|
||||||
|
cwd: params.cwd)
|
||||||
if requiresAsk {
|
switch decision {
|
||||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
case .allowOnce:
|
||||||
socketPath: approvals.socketPath,
|
break
|
||||||
token: approvals.token,
|
case .allowAlways:
|
||||||
request: ExecApprovalPromptRequest(
|
SystemRunAllowlist.add(command)
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
case .deny:
|
||||||
cwd: params.cwd,
|
|
||||||
host: "node",
|
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: agentId,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
|
||||||
case .deny?:
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
reason: "user-denied"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
case nil:
|
|
||||||
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
reason: "approval-required"))
|
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
}
|
}
|
||||||
case .allowAlways?:
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ??
|
|
||||||
resolution?.rawExecutable ??
|
|
||||||
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
|
||||||
""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce?:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: agentId,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
let env = Self.sanitizedEnv(params.env)
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
if !authorized {
|
if !authorized {
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
reason: "permission:screenRecording"))
|
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
@@ -564,30 +471,11 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.started",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command)))
|
|
||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.finished",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: ExecCommandFormatter.displayString(for: command),
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
timedOut: result.timedOut,
|
|
||||||
success: result.success,
|
|
||||||
output: ExecEventPayload.truncateOutput(combined)))
|
|
||||||
|
|
||||||
struct RunPayload: Encodable {
|
struct RunPayload: Encodable {
|
||||||
var exitCode: Int?
|
var exitCode: Int?
|
||||||
@@ -635,16 +523,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
|
||||||
guard let sender = self.eventSender else { return }
|
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sender(event, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -711,6 +589,10 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
||||||
|
SystemRunPolicy.load()
|
||||||
|
}
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemRunDecision: Sendable {
|
||||||
|
case allowOnce
|
||||||
|
case allowAlways
|
||||||
|
case deny
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@@ -17,6 +24,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
|
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -58,4 +67,30 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow this command?"
|
||||||
|
|
||||||
|
var details = "Clawdbot wants to run:\n\n\(command)"
|
||||||
|
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
|
}
|
||||||
|
details += "\n\nThis runs on this Mac via node mode."
|
||||||
|
alert.informativeText = details
|
||||||
|
|
||||||
|
alert.addButton(withTitle: "Allow Once")
|
||||||
|
alert.addButton(withTitle: "Always Allow")
|
||||||
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
|
||||||
|
switch alert.runModal() {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
return .allowOnce
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
return .allowAlways
|
||||||
|
default:
|
||||||
|
return .deny
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -580,10 +580,11 @@ final class NodePairingApprovalPrompter {
|
|||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try process.runAndReadToEnd(from: pipe)
|
try process.run()
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
return process.terminationStatus == 0
|
return process.terminationStatus == 0
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var canAdvance: Bool { !self.isWizardBlocking }
|
var canAdvance: Bool { !self.isWizardBlocking }
|
||||||
var devLinkCommand: String {
|
var devLinkCommand: String {
|
||||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||||
return "npm install -g clawdbot@\(version)"
|
return "npm install -g clawdbot@\(version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,13 +203,15 @@ actor PortGuardian {
|
|||||||
proc.standardOutput = pipe
|
proc.standardOutput = pipe
|
||||||
proc.standardError = Pipe()
|
proc.standardError = Pipe()
|
||||||
do {
|
do {
|
||||||
let data = try proc.runAndReadToEnd(from: pipe)
|
try proc.run()
|
||||||
guard !data.isEmpty else { return nil }
|
proc.waitUntilExit()
|
||||||
return String(data: data, encoding: .utf8)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
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] {
|
private static func parseListeners(from text: String) -> [Listener] {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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,7 +133,8 @@ enum RuntimeLocator {
|
|||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try process.runAndReadToEnd(from: pipe)
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
if elapsedMs > 500 {
|
if elapsedMs > 500 {
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
@@ -148,6 +149,7 @@ enum RuntimeLocator {
|
|||||||
bin=\(binary, privacy: .public)
|
bin=\(binary, privacy: .public)
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
} catch {
|
} catch {
|
||||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||||
|
case never
|
||||||
|
case ask
|
||||||
|
case always
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .never:
|
||||||
|
"Never"
|
||||||
|
case .ask:
|
||||||
|
"Always Ask"
|
||||||
|
case .always:
|
||||||
|
"Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||||
|
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
||||||
|
let policy = SystemRunPolicy(rawValue: raw)
|
||||||
|
{
|
||||||
|
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
||||||
|
let policy: SystemRunPolicy = legacy ? .ask : .never
|
||||||
|
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
let fallback: SystemRunPolicy = .ask
|
||||||
|
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SystemRunAllowlist {
|
||||||
|
static func key(for argv: [String]) -> String {
|
||||||
|
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
guard !trimmed.isEmpty else { return "" }
|
||||||
|
if let data = try? JSONEncoder().encode(trimmed),
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
return trimmed.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayString(for argv: [String]) -> String {
|
||||||
|
argv.map { arg in
|
||||||
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "\"\"" }
|
||||||
|
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||||
|
if !needsQuotes { return trimmed }
|
||||||
|
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||||
|
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||||
|
return Set(allowlist)
|
||||||
|
}
|
||||||
|
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||||
|
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||||
|
return Set(legacy)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||||
|
let key = key(for: argv)
|
||||||
|
return self.load(from: defaults).contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||||
|
let key = key(for: argv)
|
||||||
|
guard !key.isEmpty else { return }
|
||||||
|
var allowlist = self.load(from: defaults)
|
||||||
|
if allowlist.insert(key).inserted {
|
||||||
|
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SystemRunSettingsView: View {
|
|
||||||
@State private var model = ExecApprovalsSettingsModel()
|
|
||||||
@State private var tab: ExecApprovalsSettingsTab = .policy
|
|
||||||
@State private var newPattern: String = ""
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Text("Exec approvals")
|
|
||||||
.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(ExecApprovalsSettingsTab.allCases) { tab in
|
|
||||||
Text(tab.title).tag(tab)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.frame(width: 320)
|
|
||||||
|
|
||||||
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: 8) {
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { self.model.security },
|
|
||||||
set: { self.model.setSecurity($0) }))
|
|
||||||
{
|
|
||||||
ForEach(ExecSecurity.allCases) { security in
|
|
||||||
Text(security.title).tag(security)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { self.model.ask },
|
|
||||||
set: { self.model.setAsk($0) }))
|
|
||||||
{
|
|
||||||
ForEach(ExecAsk.allCases) { ask in
|
|
||||||
Text(ask.title).tag(ask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Picker("", selection: Binding(
|
|
||||||
get: { self.model.askFallback },
|
|
||||||
set: { self.model.setAskFallback($0) }))
|
|
||||||
{
|
|
||||||
ForEach(ExecSecurity.allCases) { mode in
|
|
||||||
Text("Fallback: \(mode.title)").tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
|
||||||
.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 (case-insensitive 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: \.offset) { index, _ in
|
|
||||||
ExecAllowlistRow(
|
|
||||||
entry: Binding(
|
|
||||||
get: { self.model.entries[index] },
|
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
|
|
||||||
case policy
|
|
||||||
case allowlist
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .policy: "Access"
|
|
||||||
case .allowlist: "Allowlist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecAllowlistRow: View {
|
|
||||||
@Binding var entry: ExecAllowlistEntry
|
|
||||||
let onRemove: () -> 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) {
|
|
||||||
TextField("Pattern", text: self.patternBinding)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
self.onRemove()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastUsedAt = self.entry.lastUsedAt {
|
|
||||||
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
|
|
||||||
Text("Last used \(Self.relativeFormatter.localizedString(for: date, 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
final class ExecApprovalsSettingsModel {
|
|
||||||
var agentIds: [String] = []
|
|
||||||
var selectedAgentId: String = "main"
|
|
||||||
var defaultAgentId: String = "main"
|
|
||||||
var security: ExecSecurity = .deny
|
|
||||||
var ask: ExecAsk = .onMiss
|
|
||||||
var askFallback: ExecSecurity = .deny
|
|
||||||
var autoAllowSkills = false
|
|
||||||
var entries: [ExecAllowlistEntry] = []
|
|
||||||
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) {
|
|
||||||
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
|
||||||
self.security = resolved.agent.security
|
|
||||||
self.ask = resolved.agent.ask
|
|
||||||
self.askFallback = resolved.agent.askFallback
|
|
||||||
self.autoAllowSkills = resolved.agent.autoAllowSkills
|
|
||||||
self.entries = resolved.allowlist
|
|
||||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSecurity(_ security: ExecSecurity) {
|
|
||||||
self.security = security
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.security = security
|
|
||||||
}
|
|
||||||
self.syncQuickMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAsk(_ ask: ExecAsk) {
|
|
||||||
self.ask = ask
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.ask = ask
|
|
||||||
}
|
|
||||||
self.syncQuickMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAskFallback(_ mode: ExecSecurity) {
|
|
||||||
self.askFallback = mode
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.askFallback = mode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
|
||||||
self.autoAllowSkills = enabled
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.autoAllowSkills = enabled
|
|
||||||
}
|
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
|
||||||
guard self.entries.indices.contains(index) else { return }
|
|
||||||
self.entries[index] = entry
|
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
|
||||||
guard self.entries.indices.contains(index) else { return }
|
|
||||||
self.entries.remove(at: index)
|
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func syncQuickMode() {
|
|
||||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -760,10 +760,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
public let reasoninglevel: AnyCodable?
|
public let reasoninglevel: AnyCodable?
|
||||||
public let responseusage: AnyCodable?
|
public let responseusage: AnyCodable?
|
||||||
public let elevatedlevel: AnyCodable?
|
public let elevatedlevel: AnyCodable?
|
||||||
public let exechost: AnyCodable?
|
|
||||||
public let execsecurity: AnyCodable?
|
|
||||||
public let execask: AnyCodable?
|
|
||||||
public let execnode: AnyCodable?
|
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let spawnedby: AnyCodable?
|
public let spawnedby: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
public let sendpolicy: AnyCodable?
|
||||||
@@ -777,10 +773,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
reasoninglevel: AnyCodable?,
|
reasoninglevel: AnyCodable?,
|
||||||
responseusage: AnyCodable?,
|
responseusage: AnyCodable?,
|
||||||
elevatedlevel: AnyCodable?,
|
elevatedlevel: AnyCodable?,
|
||||||
exechost: AnyCodable?,
|
|
||||||
execsecurity: AnyCodable?,
|
|
||||||
execask: AnyCodable?,
|
|
||||||
execnode: AnyCodable?,
|
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
spawnedby: AnyCodable?,
|
spawnedby: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
sendpolicy: AnyCodable?,
|
||||||
@@ -793,10 +785,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
self.reasoninglevel = reasoninglevel
|
self.reasoninglevel = reasoninglevel
|
||||||
self.responseusage = responseusage
|
self.responseusage = responseusage
|
||||||
self.elevatedlevel = elevatedlevel
|
self.elevatedlevel = elevatedlevel
|
||||||
self.exechost = exechost
|
|
||||||
self.execsecurity = execsecurity
|
|
||||||
self.execask = execask
|
|
||||||
self.execnode = execnode
|
|
||||||
self.model = model
|
self.model = model
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.sendpolicy = sendpolicy
|
self.sendpolicy = sendpolicy
|
||||||
@@ -810,10 +798,6 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case reasoninglevel = "reasoningLevel"
|
case reasoninglevel = "reasoningLevel"
|
||||||
case responseusage = "responseUsage"
|
case responseusage = "responseUsage"
|
||||||
case elevatedlevel = "elevatedLevel"
|
case elevatedlevel = "elevatedLevel"
|
||||||
case exechost = "execHost"
|
|
||||||
case execsecurity = "execSecurity"
|
|
||||||
case execask = "execAsk"
|
|
||||||
case execnode = "execNode"
|
|
||||||
case model
|
case model
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case sendpolicy = "sendPolicy"
|
case sendpolicy = "sendPolicy"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import Testing
|
|||||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||||
try self.makeExec(at: clawdbotPath)
|
try self.makeExec(at: clawdbotPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
||||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,6 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "rpc",
|
subcommand: "rpc",
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
configRoot: [:],
|
|
||||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||||
|
|
||||||
#expect(cmd.count >= 3)
|
#expect(cmd.count >= 3)
|
||||||
@@ -76,7 +75,7 @@ import Testing
|
|||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try self.makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
||||||
|
|
||||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||||
}
|
}
|
||||||
@@ -94,8 +93,7 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "health",
|
subcommand: "health",
|
||||||
extraArgs: ["--json", "--timeout", "5"],
|
extraArgs: ["--json", "--timeout", "5"],
|
||||||
defaults: defaults,
|
defaults: defaults)
|
||||||
configRoot: [:])
|
|
||||||
|
|
||||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||||
@@ -116,11 +114,7 @@ import Testing
|
|||||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
||||||
subcommand: "status",
|
|
||||||
extraArgs: ["--json"],
|
|
||||||
defaults: defaults,
|
|
||||||
configRoot: [:])
|
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
#expect(cmd.contains("clawd@example.com"))
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
struct ExecAllowlistTests {
|
|
||||||
@Test func matchUsesResolvedPath() {
|
|
||||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
|
||||||
let resolution = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
|
||||||
#expect(match?.pattern == entry.pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchUsesBasenameForSimplePattern() {
|
|
||||||
let entry = ExecAllowlistEntry(pattern: "rg")
|
|
||||||
let resolution = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
|
||||||
#expect(match?.pattern == entry.pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchIsCaseInsensitive() {
|
|
||||||
let entry = ExecAllowlistEntry(pattern: "RG")
|
|
||||||
let resolution = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
|
||||||
#expect(match?.pattern == entry.pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func matchSupportsGlobStar() {
|
|
||||||
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
|
|
||||||
let resolution = ExecCommandResolution(
|
|
||||||
rawExecutable: "rg",
|
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
|
||||||
executableName: "rg",
|
|
||||||
cwd: nil)
|
|
||||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
|
||||||
#expect(match?.pattern == entry.pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,28 +5,16 @@ import Testing
|
|||||||
@Suite struct GatewayEnvironmentTests {
|
@Suite struct GatewayEnvironmentTests {
|
||||||
@Test func semverParsesCommonForms() {
|
@Test func semverParsesCommonForms() {
|
||||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
#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("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
|
||||||
#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(nil) == nil)
|
||||||
#expect(Semver.parse("invalid") == nil)
|
#expect(Semver.parse("invalid") == nil)
|
||||||
#expect(Semver.parse("1.2") == nil)
|
|
||||||
#expect(Semver.parse("1.2.x") == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
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: 1, patch: 0).compatible(with: required))
|
||||||
#expect(Semver(major: 2, minor: 2, 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: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||||
}
|
}
|
||||||
@@ -48,7 +36,6 @@ import Testing
|
|||||||
|
|
||||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
#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)
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,10 @@ struct MacNodeRuntimeTests {
|
|||||||
{
|
{
|
||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||||
|
.allowOnce
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let services = await MainActor.run { FakeMainActorServices() }
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import Testing
|
|||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||||
|
|
||||||
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
||||||
#expect(settings.mode == .remote)
|
#expect(settings.mode == .remote)
|
||||||
#expect(settings.target == "alice@example.com")
|
#expect(settings.target == "alice@example.com")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,25 +24,19 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var env: [String: String]?
|
public var env: [String: String]?
|
||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
public var needsScreenRecording: Bool?
|
public var needsScreenRecording: Bool?
|
||||||
public var agentId: String?
|
|
||||||
public var sessionKey: String?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
cwd: String? = nil,
|
cwd: String? = nil,
|
||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
needsScreenRecording: Bool? = nil,
|
needsScreenRecording: Bool? = nil)
|
||||||
agentId: String? = nil,
|
|
||||||
sessionKey: String? = nil)
|
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.env = env
|
self.env = env
|
||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
self.needsScreenRecording = needsScreenRecording
|
self.needsScreenRecording = needsScreenRecording
|
||||||
self.agentId = agentId
|
|
||||||
self.sessionKey = sessionKey
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
|
||||||
read_when:
|
|
||||||
- Setting up BlueBubbles channel
|
|
||||||
- Troubleshooting webhook pairing
|
|
||||||
---
|
|
||||||
# BlueBubbles (macOS REST)
|
|
||||||
|
|
||||||
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
|
||||||
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
|
||||||
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
|
||||||
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
|
||||||
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
|
||||||
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
|
||||||
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
|
||||||
3. Configure Clawdbot:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
bluebubbles: {
|
|
||||||
enabled: true,
|
|
||||||
serverUrl: "http://bluebubbles-host:1234",
|
|
||||||
password: "example-password",
|
|
||||||
webhookPath: "/bluebubbles-webhook",
|
|
||||||
actions: { reactions: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
|
||||||
5. Start the gateway; it will register the webhook handler and start pairing.
|
|
||||||
|
|
||||||
## Configuration notes
|
|
||||||
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
|
||||||
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
|
||||||
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
|
||||||
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
|
||||||
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
|
||||||
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
|
||||||
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
|
||||||
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
|
||||||
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
|
||||||
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
|
||||||
|
|
||||||
## Security
|
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
|
||||||
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
|
||||||
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
|
||||||
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
|
||||||
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
|
||||||
|
|
||||||
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
|
||||||
@@ -58,7 +58,7 @@ Minimal config:
|
|||||||
- The `discord` tool is only exposed when the current channel is Discord.
|
- 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.
|
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
||||||
|
|
||||||
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: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
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.
|
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||||
|
|
||||||
@@ -175,7 +175,6 @@ Notes:
|
|||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
- `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`.
|
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||||
- If `channels` is present, any channel not listed is denied by default.
|
- 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).
|
- 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`.
|
- 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`.
|
||||||
|
|
||||||
@@ -193,11 +192,8 @@ Notes:
|
|||||||
- Your config requires mentions and you didn’t mention it, or
|
- Your config requires mentions and you didn’t mention it, or
|
||||||
- Your guild/channel allowlist denies the channel/user.
|
- Your guild/channel allowlist denies the channel/user.
|
||||||
- **`requireMention: false` but still no replies**:
|
- **`requireMention: false` but still no replies**:
|
||||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
- `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
|
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||||
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.
|
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
||||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||||
|
|
||||||
@@ -365,15 +361,10 @@ Allowlist matching notes:
|
|||||||
- Use `*` to allow any sender/channel.
|
- Use `*` to allow any sender/channel.
|
||||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||||
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
- 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:
|
Native command notes:
|
||||||
- The registered commands mirror Clawdbot’s chat commands.
|
- 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).
|
- 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
|
## Tool actions
|
||||||
The agent can call `discord` with actions like:
|
The agent can call `discord` with actions like:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||||
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
|
|||||||
@@ -70,10 +70,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
|||||||
- `clawdbot pairing list matrix`
|
- `clawdbot pairing list matrix`
|
||||||
- `clawdbot pairing approve matrix <CODE>`
|
- `clawdbot pairing approve matrix <CODE>`
|
||||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
- 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)
|
## Rooms (groups)
|
||||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
|
||||||
- Allowlist rooms with `channels.matrix.rooms`:
|
- Allowlist rooms with `channels.matrix.rooms`:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -87,9 +86,6 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `requireMention: false` enables auto-reply in that room.
|
- `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
|
## Threads
|
||||||
- Reply threading is supported.
|
- Reply threading is supported.
|
||||||
|
|||||||
@@ -76,13 +76,12 @@ Disable with:
|
|||||||
|
|
||||||
**DM access**
|
**DM access**
|
||||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
|
||||||
|
|
||||||
**Group access**
|
**Group access**
|
||||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
|
||||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
- `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).
|
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
||||||
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```json5
|
```json5
|
||||||
@@ -96,32 +95,6 @@ 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
|
## How it works
|
||||||
1. Install the Microsoft Teams plugin.
|
1. Install the Microsoft Teams plugin.
|
||||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||||
|
|||||||
@@ -335,7 +335,6 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
- 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`).
|
- 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.
|
- 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)
|
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||||
|
|
||||||
@@ -343,19 +342,10 @@ 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).
|
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
- 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
|
## Group policy
|
||||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||||
- `allowlist` requires channels to be listed in `channels.slack.channels`.
|
- `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>`):
|
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ 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.
|
**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:
|
To allow all groups with always-respond:
|
||||||
```json5
|
```json5
|
||||||
@@ -217,7 +216,6 @@ 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.
|
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||||
- 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.
|
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.
|
||||||
|
|
||||||
@@ -362,19 +360,6 @@ 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.
|
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)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||||
|
|||||||
@@ -66,36 +66,11 @@ clawdbot directory groups list --channel zalouser --query "work"
|
|||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
`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:
|
Approve via:
|
||||||
- `clawdbot pairing list zalouser`
|
- `clawdbot pairing list zalouser`
|
||||||
- `clawdbot pairing approve zalouser <code>`
|
- `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
|
## Multi-account
|
||||||
Accounts map to zca profiles. Example:
|
Accounts map to zca profiles. Example:
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ Related docs:
|
|||||||
```bash
|
```bash
|
||||||
clawdbot channels list
|
clawdbot channels list
|
||||||
clawdbot channels status
|
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
|
clawdbot channels logs --channel all
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,30 +42,3 @@ clawdbot channels logout --channel whatsapp
|
|||||||
- Run `clawdbot status --deep` for a broad probe.
|
- Run `clawdbot status --deep` for a broad probe.
|
||||||
- Use `clawdbot doctor` for guided fixes.
|
- 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,7 +17,6 @@ Related:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
- 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
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ clawdbot doctor --repair
|
|||||||
clawdbot doctor --deep
|
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
|
## 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.
|
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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.
|
- 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).
|
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
- `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
|
### Options
|
||||||
|
|
||||||
|
|||||||
+1
-18
@@ -11,7 +11,6 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
|
|||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
|
||||||
|
|
||||||
## List All Hooks
|
## List All Hooks
|
||||||
|
|
||||||
@@ -29,12 +28,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (3/3 ready)
|
Hooks (2/2 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example (verbose):**
|
**Example (verbose):**
|
||||||
@@ -120,9 +118,6 @@ clawdbot hooks enable <name>
|
|||||||
|
|
||||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||||
|
|
||||||
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
|
|
||||||
can’t be enabled/disabled here. Enable/disable the plugin instead.
|
|
||||||
|
|
||||||
**Arguments:**
|
**Arguments:**
|
||||||
- `<name>`: Hook name (e.g., `session-memory`)
|
- `<name>`: Hook name (e.g., `session-memory`)
|
||||||
|
|
||||||
@@ -261,15 +256,3 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [command-logger documentation](/hooks#command-logger)
|
**See:** [command-logger documentation](/hooks#command-logger)
|
||||||
|
|
||||||
### soul-evil
|
|
||||||
|
|
||||||
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
|
||||||
|
|
||||||
**Enable:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable soul-evil
|
|
||||||
```
|
|
||||||
|
|
||||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
|
||||||
+3
-4
@@ -292,7 +292,7 @@ Options:
|
|||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--flow <quickstart|advanced>`
|
- `--flow <quickstart|advanced>`
|
||||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|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>`
|
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||||
@@ -302,7 +302,6 @@ Options:
|
|||||||
- `--openrouter-api-key <key>`
|
- `--openrouter-api-key <key>`
|
||||||
- `--ai-gateway-api-key <key>`
|
- `--ai-gateway-api-key <key>`
|
||||||
- `--moonshot-api-key <key>`
|
- `--moonshot-api-key <key>`
|
||||||
- `--kimi-code-api-key <key>`
|
|
||||||
- `--gemini-api-key <key>`
|
- `--gemini-api-key <key>`
|
||||||
- `--zai-api-key <key>`
|
- `--zai-api-key <key>`
|
||||||
- `--minimax-api-key <key>`
|
- `--minimax-api-key <key>`
|
||||||
@@ -522,13 +521,13 @@ Options:
|
|||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
Surfaces:
|
Surfaces:
|
||||||
- `/status` (adds a short provider usage line when available)
|
- `/status` (alias: `/usage`; adds a short usage line when available)
|
||||||
- `clawdbot status --usage` (prints full provider breakdown)
|
- `clawdbot status --usage` (prints full provider breakdown)
|
||||||
- macOS menu bar (Usage section under Context)
|
- macOS menu bar (Usage section under Context)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Data comes directly from provider usage endpoints (no estimates).
|
- Data comes directly from provider usage endpoints (no estimates).
|
||||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||||
- If no matching credentials exist, usage is hidden.
|
- If no matching credentials exist, usage is hidden.
|
||||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,15 @@ read_when:
|
|||||||
# `clawdbot memory`
|
# `clawdbot memory`
|
||||||
|
|
||||||
Memory search tools (semantic memory status/index/search).
|
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:
|
Related:
|
||||||
- Memory concept: [Memory](/concepts/memory)
|
- Memory concept: [Memory](/concepts/memory)
|
||||||
- Plugins: [Plugins](/plugins)
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot memory status
|
clawdbot memory status
|
||||||
clawdbot memory status --deep
|
|
||||||
clawdbot memory status --deep --index
|
|
||||||
clawdbot memory status --deep --index --verbose
|
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
|
||||||
@@ -26,11 +26,6 @@ clawdbot models scan
|
|||||||
When provider usage snapshots are available, the OAuth/token status section includes
|
When provider usage snapshots are available, the OAuth/token status section includes
|
||||||
provider usage headers.
|
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
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ clawdbot plugins update <id>
|
|||||||
clawdbot plugins update --all
|
clawdbot plugins update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
|
||||||
activate them.
|
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
|
||||||
@@ -15,8 +15,6 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
clawdbot update --channel beta
|
|
||||||
clawdbot update --tag beta
|
|
||||||
clawdbot update --restart
|
clawdbot update --restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
clawdbot --update
|
clawdbot --update
|
||||||
@@ -25,13 +23,9 @@ clawdbot --update
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--restart`: restart the Gateway daemon after a successful update.
|
- `--restart`: restart the Gateway daemon after a successful update.
|
||||||
- `--channel <stable|beta>`: 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.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||||
|
|
||||||
Note: downgrades require confirmation because older versions can break configuration.
|
|
||||||
|
|
||||||
## What it does (git checkout)
|
## What it does (git checkout)
|
||||||
|
|
||||||
High-level:
|
High-level:
|
||||||
|
|||||||
@@ -98,14 +98,6 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
|||||||
streams tool output via agent events when available.
|
streams tool output via agent events when available.
|
||||||
More details: [Streaming + chunking](/concepts/streaming).
|
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)
|
## Configuration (minimal)
|
||||||
|
|
||||||
At minimum, set:
|
At minimum, set:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an
|
|||||||
- `/status` → quick “how full is my window?” view + session settings.
|
- `/status` → quick “how full is my window?” view + session settings.
|
||||||
- `/context list` → what’s injected + rough sizes (per file + totals).
|
- `/context list` → what’s injected + rough sizes (per file + totals).
|
||||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
||||||
- `/usage tokens` → append per-reply usage footer to normal replies.
|
- `/cost on` → append per-reply usage line to normal replies.
|
||||||
- `/compact` → summarize older history into a compact entry to free window space.
|
- `/compact` → summarize older history into a compact entry to free window space.
|
||||||
|
|
||||||
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
||||||
@@ -149,3 +149,4 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session
|
|||||||
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
||||||
|
|
||||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||||
|
|
||||||
+2
-161
@@ -9,9 +9,6 @@ read_when:
|
|||||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
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.
|
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)
|
## Memory files (Markdown)
|
||||||
|
|
||||||
The default workspace layout uses two memory layers:
|
The default workspace layout uses two memory layers:
|
||||||
@@ -81,7 +78,6 @@ Defaults:
|
|||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- 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
|
Remote embeddings **require** an API key for the embedding provider. By default
|
||||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||||
@@ -111,19 +107,6 @@ agents: {
|
|||||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`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:
|
Config example:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -133,9 +116,6 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
fallback: "openai",
|
fallback: "openai",
|
||||||
remote: {
|
|
||||||
batch: { enabled: true, concurrency: 2 }
|
|
||||||
},
|
|
||||||
sync: { watch: true }
|
sync: { watch: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,147 +140,8 @@ Local mode:
|
|||||||
### What gets indexed (and when)
|
### What gets indexed (and when)
|
||||||
|
|
||||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
- 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.
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
#### Why hybrid?
|
|
||||||
|
|
||||||
Vector search is great at “this means the same thing”:
|
|
||||||
- “Mac Studio gateway host” vs “the machine running the gateway”
|
|
||||||
- “debounce file updates” vs “avoid indexing on every write”
|
|
||||||
|
|
||||||
But it can be weak at exact, high-signal tokens:
|
|
||||||
- IDs (`a828e60`, `b3b9895a…`)
|
|
||||||
- code symbols (`memorySearch.query.hybrid`)
|
|
||||||
- error strings (“sqlite-vec unavailable”)
|
|
||||||
|
|
||||||
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
|
|
||||||
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
|
|
||||||
good results for both “natural language” queries and “needle in a haystack” queries.
|
|
||||||
|
|
||||||
#### How we merge results (the current design)
|
|
||||||
|
|
||||||
Implementation sketch:
|
|
||||||
|
|
||||||
1) Retrieve a candidate pool from both sides:
|
|
||||||
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
|
|
||||||
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
|
|
||||||
|
|
||||||
2) Convert BM25 rank into a 0..1-ish score:
|
|
||||||
- `textScore = 1 / (1 + max(0, bm25Rank))`
|
|
||||||
|
|
||||||
3) Union candidates by chunk id and compute a weighted score:
|
|
||||||
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
|
|
||||||
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
|
|
||||||
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
|
|
||||||
|
|
||||||
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
|
|
||||||
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
|
|
||||||
(min/max or z-score) before mixing.
|
|
||||||
|
|
||||||
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
|
### Local embedding auto-download
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
|||||||
|
|
||||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
|
||||||
- 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)
|
### Z.AI (GLM)
|
||||||
|
|
||||||
@@ -155,50 +150,6 @@ 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
|
||||||
|
|
||||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ Notes:
|
|||||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||||
- `/model <#>` selects from that picker.
|
- `/model <#>` selects from that picker.
|
||||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
- `/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).
|
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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`).
|
- 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.
|
- 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.
|
- 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.
|
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
## Session pruning
|
## Session pruning
|
||||||
@@ -54,12 +53,8 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||||
- Node bridge runs: `node-<nodeId>`
|
- Node bridge runs: `node-<nodeId>`
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecyle
|
||||||
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
|
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||||
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
|
|
||||||
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
|
||||||
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
|
||||||
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
|
||||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||||
@@ -97,18 +92,7 @@ Send these as standalone messages so they register.
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
},
|
},
|
||||||
reset: {
|
idleMinutes: 120,
|
||||||
// Defaults: mode=daily, atHour=4 (gateway host local time).
|
|
||||||
// If you also set idleMinutes, whichever expires first wins.
|
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 120
|
|
||||||
},
|
|
||||||
resetByType: {
|
|
||||||
thread: { mode: "daily", atHour: 4 },
|
|
||||||
dm: { mode: "idle", idleMinutes: 240 },
|
|
||||||
group: { mode: "idle", idleMinutes: 120 }
|
|
||||||
},
|
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
mainKey: "main",
|
mainKey: "main",
|
||||||
@@ -129,18 +113,3 @@ Send these as standalone messages so they register.
|
|||||||
## Tips
|
## Tips
|
||||||
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
- 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.
|
- 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
|
|
||||||
The origin fields are populated for direct messages, channels, and groups. If a
|
|
||||||
connector only updates delivery routing (for example, to keep a DM main session
|
|
||||||
fresh), it should still provide inbound context so the session keeps its
|
|
||||||
explainer metadata. Extensions can do this by sending `ConversationLabel`,
|
|
||||||
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
|
|
||||||
context and calling `recordSessionMetaFromInbound` (or passing the same context
|
|
||||||
to `updateLastRoute`).
|
|
||||||
@@ -58,9 +58,6 @@ Large files are truncated with a marker. The max per-file size is controlled by
|
|||||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||||
short missing-file marker.
|
short missing-file marker.
|
||||||
|
|
||||||
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
|
|
||||||
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).
|
|
||||||
|
|
||||||
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
||||||
|
|
||||||
## Time handling
|
## Time handling
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ read_when:
|
|||||||
|
|
||||||
## Where it shows up
|
## Where it shows up
|
||||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||||
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||||
- macOS menu bar: “Usage” section under Context (only if available).
|
- macOS menu bar: “Usage” section under Context (only if available).
|
||||||
|
|||||||
+1
-2
@@ -894,6 +894,7 @@
|
|||||||
"gateway/heartbeat",
|
"gateway/heartbeat",
|
||||||
"gateway/doctor",
|
"gateway/doctor",
|
||||||
"gateway/logging",
|
"gateway/logging",
|
||||||
|
"logging",
|
||||||
"gateway/security",
|
"gateway/security",
|
||||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||||
"gateway/sandboxing",
|
"gateway/sandboxing",
|
||||||
@@ -956,8 +957,6 @@
|
|||||||
{
|
{
|
||||||
"group": "Automation & Hooks",
|
"group": "Automation & Hooks",
|
||||||
"pages": [
|
"pages": [
|
||||||
"hooks",
|
|
||||||
"hooks/soul-evil",
|
|
||||||
"automation/auth-monitoring",
|
"automation/auth-monitoring",
|
||||||
"automation/webhook",
|
"automation/webhook",
|
||||||
"automation/gmail-pubsub",
|
"automation/gmail-pubsub",
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ When spawning long-running child processes outside the exec/process tools (for e
|
|||||||
Environment overrides:
|
Environment overrides:
|
||||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
- `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)
|
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||||
|
|
||||||
Config (preferred):
|
Config (preferred):
|
||||||
|
|||||||
@@ -146,11 +146,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
// Session behavior
|
// Session behavior
|
||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
reset: {
|
idleMinutes: 60,
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 60
|
|
||||||
},
|
|
||||||
heartbeatIdleMinutes: 120,
|
heartbeatIdleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
||||||
|
|||||||
@@ -678,11 +678,10 @@ Notes:
|
|||||||
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
||||||
- `"disabled"`: block all group/room messages.
|
- `"disabled"`: block all group/room messages.
|
||||||
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
- `"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`).
|
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||||
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
||||||
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
||||||
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
|
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
|
||||||
|
|
||||||
### Multi-agent routing (`agents.list` + `bindings`)
|
### Multi-agent routing (`agents.list` + `bindings`)
|
||||||
|
|
||||||
@@ -2235,49 +2234,6 @@ Notes:
|
|||||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
- 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)
|
### Synthetic (Anthropic-compatible)
|
||||||
|
|
||||||
Use Synthetic's Anthropic-compatible endpoint:
|
Use Synthetic's Anthropic-compatible endpoint:
|
||||||
@@ -2416,7 +2372,7 @@ Notes:
|
|||||||
|
|
||||||
### `session`
|
### `session`
|
||||||
|
|
||||||
Controls session scoping, reset policy, reset triggers, and where the session store is written.
|
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -2426,16 +2382,7 @@ Controls session scoping, reset policy, reset triggers, and where the session st
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
},
|
},
|
||||||
reset: {
|
idleMinutes: 60,
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 60
|
|
||||||
},
|
|
||||||
resetByType: {
|
|
||||||
thread: { mode: "daily", atHour: 4 },
|
|
||||||
dm: { mode: "idle", idleMinutes: 240 },
|
|
||||||
group: { mode: "idle", idleMinutes: 120 }
|
|
||||||
},
|
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
// You can override with {agentId} templating:
|
// You can override with {agentId} templating:
|
||||||
@@ -2446,12 +2393,12 @@ Controls session scoping, reset policy, reset triggers, and where the session st
|
|||||||
// Max ping-pong reply turns between requester/target (0–5).
|
// Max ping-pong reply turns between requester/target (0–5).
|
||||||
maxPingPongTurns: 5
|
maxPingPongTurns: 5
|
||||||
},
|
},
|
||||||
sendPolicy: {
|
sendPolicy: {
|
||||||
rules: [
|
rules: [
|
||||||
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
||||||
],
|
],
|
||||||
default: "allow"
|
default: "allow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -2465,13 +2412,6 @@ Fields:
|
|||||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
||||||
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
|
||||||
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
|
||||||
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
|
|
||||||
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
|
|
||||||
- `atHour`: local hour (0-23) for the daily reset boundary.
|
|
||||||
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
|
||||||
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
|
||||||
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
|
||||||
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
|
|
||||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
|
|||||||
@@ -52,14 +52,6 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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)
|
## 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:
|
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||||
|
|||||||
@@ -239,15 +239,11 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
|
|||||||
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check 2:** Is the reset window too short?
|
**Check 2:** Is `idleMinutes` too short?
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"session": {
|
"session": {
|
||||||
"reset": {
|
"idleMinutes": 10080 // 7 days
|
||||||
"mode": "daily",
|
|
||||||
"atHour": 4,
|
|
||||||
"idleMinutes": 10080 // 7 days
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-46
@@ -14,8 +14,6 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
|||||||
|
|
||||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||||
|
|
||||||
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
|
|
||||||
|
|
||||||
Common uses:
|
Common uses:
|
||||||
- Save a memory snapshot when you reset a session
|
- Save a memory snapshot when you reset a session
|
||||||
@@ -37,11 +35,10 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### Bundled Hooks
|
||||||
|
|
||||||
Clawdbot ships with three bundled hooks that are automatically discovered:
|
Clawdbot ships with two bundled hooks that are automatically discovered:
|
||||||
|
|
||||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
|
|
||||||
@@ -206,8 +203,6 @@ Each event includes:
|
|||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
||||||
senderId?: string,
|
senderId?: string,
|
||||||
workspaceDir?: string,
|
|
||||||
bootstrapFiles?: WorkspaceBootstrapFile[],
|
|
||||||
cfg?: ClawdbotConfig
|
cfg?: ClawdbotConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,10 +219,6 @@ Triggered when agent commands are issued:
|
|||||||
- **`command:reset`**: When `/reset` command is issued
|
- **`command:reset`**: When `/reset` command is issued
|
||||||
- **`command:stop`**: When `/stop` command is issued
|
- **`command:stop`**: When `/stop` command is issued
|
||||||
|
|
||||||
### Agent Events
|
|
||||||
|
|
||||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -506,42 +497,6 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|||||||
clawdbot hooks enable command-logger
|
clawdbot hooks enable command-logger
|
||||||
```
|
```
|
||||||
|
|
||||||
### soul-evil
|
|
||||||
|
|
||||||
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
|
||||||
|
|
||||||
**Events**: `agent:bootstrap`
|
|
||||||
|
|
||||||
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
|
|
||||||
|
|
||||||
**Output**: No files written; swaps happen in-memory only.
|
|
||||||
|
|
||||||
**Enable**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable soul-evil
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"internal": {
|
|
||||||
"enabled": true,
|
|
||||||
"entries": {
|
|
||||||
"soul-evil": {
|
|
||||||
"enabled": true,
|
|
||||||
"file": "SOUL_EVIL.md",
|
|
||||||
"chance": 0.1,
|
|
||||||
"purge": { "at": "21:00", "duration": "15m" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
|
|
||||||
read_when:
|
|
||||||
- You want to enable or tune the SOUL Evil hook
|
|
||||||
- You want a purge window or random-chance persona swap
|
|
||||||
---
|
|
||||||
|
|
||||||
# SOUL Evil Hook
|
|
||||||
|
|
||||||
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
|
|
||||||
a purge window or by random chance. It does **not** modify files on disk.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
|
|
||||||
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
|
|
||||||
Clawdbot logs a warning and keeps the normal `SOUL.md`.
|
|
||||||
|
|
||||||
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
|
|
||||||
has no effect on sub-agents.
|
|
||||||
|
|
||||||
## Enable
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable soul-evil
|
|
||||||
```
|
|
||||||
|
|
||||||
Then set the config:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"internal": {
|
|
||||||
"enabled": true,
|
|
||||||
"entries": {
|
|
||||||
"soul-evil": {
|
|
||||||
"enabled": true,
|
|
||||||
"file": "SOUL_EVIL.md",
|
|
||||||
"chance": 0.1,
|
|
||||||
"purge": { "at": "21:00", "duration": "15m" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
|
|
||||||
- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md`
|
|
||||||
- `purge.at` (HH:mm): daily purge start (24-hour clock)
|
|
||||||
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
|
|
||||||
|
|
||||||
**Precedence:** purge window wins over chance.
|
|
||||||
|
|
||||||
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- No files are written or modified on disk.
|
|
||||||
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Hooks](/hooks)
|
|
||||||
@@ -50,22 +50,6 @@ pnpm add -g clawdbot@latest
|
|||||||
```
|
```
|
||||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
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:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,7 +75,7 @@ It runs a safe-ish update flow:
|
|||||||
- Fetches + rebases against the configured upstream.
|
- Fetches + rebases against the configured upstream.
|
||||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||||
|
|
||||||
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.
|
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
||||||
|
|
||||||
## Update (Control UI / RPC)
|
## Update (Control UI / RPC)
|
||||||
|
|
||||||
|
|||||||
@@ -62,25 +62,8 @@ read_when:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Provider-only (Deepgram)
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
tools: {
|
|
||||||
media: {
|
|
||||||
audio: {
|
|
||||||
enabled: true,
|
|
||||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes & limits
|
## Notes & limits
|
||||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
- 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 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.
|
- 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`).
|
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ 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.models`: shared model list (use `capabilities` to gate).
|
||||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
- 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)
|
- optional **per‑capability `models` list** (preferred before shared models)
|
||||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||||
- `scope` (optional gating by channel/chatType/session key)
|
- `scope` (optional gating by channel/chatType/session key)
|
||||||
@@ -110,7 +108,6 @@ lists, Clawdbot can infer defaults:
|
|||||||
- `openai`, `anthropic`, `minimax`: **image**
|
- `openai`, `anthropic`, `minimax`: **image**
|
||||||
- `google` (Gemini API): **image + audio + video**
|
- `google` (Gemini API): **image + audio + video**
|
||||||
- `groq`: **audio**
|
- `groq`: **audio**
|
||||||
- `deepgram`: **audio**
|
|
||||||
|
|
||||||
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
||||||
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||||
@@ -119,7 +116,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
|||||||
| Capability | Provider integration | Notes |
|
| Capability | Provider integration | Notes |
|
||||||
|------------|----------------------|-------|
|
|------------|----------------------|-------|
|
||||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
|
||||||
| Video | Google (Gemini API) | Provider video understanding. |
|
| Video | Google (Gemini API) | Provider video understanding. |
|
||||||
|
|
||||||
## Recommended providers
|
## Recommended providers
|
||||||
@@ -128,9 +125,8 @@ 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`.
|
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||||
|
|
||||||
**Audio**
|
**Audio**
|
||||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
|
||||||
- CLI fallback: `whisper` binary.
|
- CLI fallback: `whisper` binary.
|
||||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
|
||||||
|
|
||||||
**Video**
|
**Video**
|
||||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
+22
-62
@@ -36,16 +36,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
## Available plugins (official)
|
## Available plugins (official)
|
||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- 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`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
- [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
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
@@ -58,32 +53,21 @@ register:
|
|||||||
- Optional config validation
|
- Optional config validation
|
||||||
|
|
||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
|
||||||
|
|
||||||
## Discovery & precedence
|
## Discovery & precedence
|
||||||
|
|
||||||
Clawdbot scans, in order:
|
Clawdbot scans, in order:
|
||||||
|
|
||||||
1) Config paths
|
1) Global extensions
|
||||||
- `plugins.load.paths` (file or directory)
|
- `~/.clawdbot/extensions/*.ts`
|
||||||
|
- `~/.clawdbot/extensions/*/index.ts`
|
||||||
|
|
||||||
2) Workspace extensions
|
2) Workspace extensions
|
||||||
- `<workspace>/.clawdbot/extensions/*.ts`
|
- `<workspace>/.clawdbot/extensions/*.ts`
|
||||||
- `<workspace>/.clawdbot/extensions/*/index.ts`
|
- `<workspace>/.clawdbot/extensions/*/index.ts`
|
||||||
|
|
||||||
3) Global extensions
|
3) Config paths
|
||||||
- `~/.clawdbot/extensions/*.ts`
|
- `plugins.load.paths` (file or directory)
|
||||||
- `~/.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
|
### Package packs
|
||||||
|
|
||||||
@@ -139,24 +123,6 @@ Fields:
|
|||||||
|
|
||||||
Config changes **require a gateway restart**.
|
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)
|
## Control UI (schema + labels)
|
||||||
|
|
||||||
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
||||||
@@ -215,27 +181,6 @@ Plugins export either:
|
|||||||
- A function: `(api) => { ... }`
|
- A function: `(api) => { ... }`
|
||||||
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
- An object: `{ id, name, configSchema, register(api) { ... } }`
|
||||||
|
|
||||||
## Plugin hooks
|
|
||||||
|
|
||||||
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
|
|
||||||
event-driven automation without a separate hook pack install.
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```
|
|
||||||
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
export default function register(api) {
|
|
||||||
registerPluginHooksFromDir(api, "./hooks");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
|
|
||||||
- Hook eligibility rules still apply (OS/bins/env/config requirements).
|
|
||||||
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
|
|
||||||
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
|
|
||||||
|
|
||||||
## Provider plugins (model auth)
|
## Provider plugins (model auth)
|
||||||
|
|
||||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||||
@@ -401,9 +346,24 @@ export default function (api) {
|
|||||||
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
||||||
then configure `channels.<id>` in your config.
|
then configure `channels.<id>` in your config.
|
||||||
|
|
||||||
### Agent tools
|
### Register a tool
|
||||||
|
|
||||||
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
|
```ts
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export default function (api) {
|
||||||
|
api.registerTool({
|
||||||
|
name: "my_tool",
|
||||||
|
description: "Do a thing",
|
||||||
|
parameters: Type.Object({
|
||||||
|
input: Type.String(),
|
||||||
|
}),
|
||||||
|
async execute(_id, params) {
|
||||||
|
return { content: [{ type: "text", text: params.input }] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Register a gateway RPC method
|
### Register a gateway RPC method
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
|
|
||||||
read_when:
|
|
||||||
- You want to add a new agent tool in a plugin
|
|
||||||
- You need to make a tool opt-in via allowlists
|
|
||||||
---
|
|
||||||
# Plugin agent tools
|
|
||||||
|
|
||||||
Clawdbot plugins can register **agent tools** (JSON‑schema functions) that are exposed
|
|
||||||
to the LLM during agent runs. Tools can be **required** (always available) or
|
|
||||||
**optional** (opt‑in).
|
|
||||||
|
|
||||||
Agent tools are configured under `tools` in the main config, or per‑agent under
|
|
||||||
`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
|
|
||||||
can call.
|
|
||||||
|
|
||||||
## Basic tool
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
export default function (api) {
|
|
||||||
api.registerTool({
|
|
||||||
name: "my_tool",
|
|
||||||
description: "Do a thing",
|
|
||||||
parameters: Type.Object({
|
|
||||||
input: Type.String(),
|
|
||||||
}),
|
|
||||||
async execute(_id, params) {
|
|
||||||
return { content: [{ type: "text", text: params.input }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional tool (opt‑in)
|
|
||||||
|
|
||||||
Optional tools are **never** auto‑enabled. Users must add them to an agent
|
|
||||||
allowlist.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export default function (api) {
|
|
||||||
api.registerTool(
|
|
||||||
{
|
|
||||||
name: "workflow_tool",
|
|
||||||
description: "Run a local workflow",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
pipeline: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["pipeline"],
|
|
||||||
},
|
|
||||||
async execute(_id, params) {
|
|
||||||
return { content: [{ type: "text", text: params.pipeline }] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ optional: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "main",
|
|
||||||
tools: {
|
|
||||||
allow: [
|
|
||||||
"workflow_tool", // specific tool name
|
|
||||||
"workflow", // plugin id (enables all tools from that plugin)
|
|
||||||
"group:plugins" // all plugin tools
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Other config knobs that affect tool availability:
|
|
||||||
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
|
||||||
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
|
||||||
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
|
||||||
|
|
||||||
## Rules + tips
|
|
||||||
|
|
||||||
- Tool names must **not** clash with core tool names; conflicting tools are skipped.
|
|
||||||
- Plugin ids used in allowlists must not clash with core tool names.
|
|
||||||
- Prefer `optional: true` for tools that trigger side effects or require extra
|
|
||||||
binaries/credentials.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
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).
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
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,18 +26,13 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
|||||||
|
|
||||||
- [OpenAI (API + Codex)](/providers/openai)
|
- [OpenAI (API + Codex)](/providers/openai)
|
||||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||||
- [Qwen (OAuth)](/providers/qwen)
|
|
||||||
- [OpenRouter](/providers/openrouter)
|
- [OpenRouter](/providers/openrouter)
|
||||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||||
- [OpenCode Zen](/providers/opencode)
|
- [OpenCode Zen](/providers/opencode)
|
||||||
- [Z.AI](/providers/zai)
|
- [Z.AI](/providers/zai)
|
||||||
- [GLM models](/providers/glm)
|
- [GLM models](/providers/glm)
|
||||||
- [MiniMax](/providers/minimax)
|
- [MiniMax](/providers/minimax)
|
||||||
|
|
||||||
## Transcription providers
|
|
||||||
|
|
||||||
- [Deepgram (audio transcription)](/providers/deepgram)
|
|
||||||
|
|
||||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||||
see [Model providers](/concepts/model-providers).
|
see [Model providers](/concepts/model-providers).
|
||||||
@@ -155,7 +155,6 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Model refs are `minimax/<model>`.
|
- 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.
|
- 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
|
- 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.
|
- 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)
|
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||||
- [OpenRouter](/providers/openrouter)
|
- [OpenRouter](/providers/openrouter)
|
||||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||||
- [Synthetic](/providers/synthetic)
|
- [Synthetic](/providers/synthetic)
|
||||||
- [OpenCode Zen](/providers/opencode)
|
- [OpenCode Zen](/providers/opencode)
|
||||||
- [Z.AI](/providers/zai)
|
- [Z.AI](/providers/zai)
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
---
|
---
|
||||||
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
|
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
|
||||||
read_when:
|
read_when:
|
||||||
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
|
- You want to use Moonshot/Kimi models in Clawdbot
|
||||||
- You need to understand separate endpoints, keys, and model refs
|
- You need the Moonshot auth + config example
|
||||||
- You want copy/paste config for either provider
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Moonshot AI (Kimi)
|
# Moonshot AI (Kimi)
|
||||||
|
|
||||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
|
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
|
||||||
Kimi Code with `kimi-code/kimi-for-coding`.
|
|
||||||
|
|
||||||
Current Kimi K2 model IDs:
|
Current Kimi K2 model IDs:
|
||||||
{/* moonshot-kimi-k2-ids:start */}
|
{/* moonshot-kimi-k2-ids:start */}
|
||||||
@@ -24,15 +21,7 @@ Current Kimi K2 model IDs:
|
|||||||
clawdbot onboard --auth-choice moonshot-api-key
|
clawdbot onboard --auth-choice moonshot-api-key
|
||||||
```
|
```
|
||||||
|
|
||||||
Kimi Code:
|
## Config snippet
|
||||||
|
|
||||||
```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
|
```json5
|
||||||
{
|
{
|
||||||
@@ -103,48 +92,9 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## Notes
|
||||||
|
|
||||||
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
|
- Model refs use `moonshot/<modelId>`.
|
||||||
- Override pricing and context metadata in `models.providers` if needed.
|
- Override pricing and context metadata in `models.providers` if needed.
|
||||||
- If Moonshot publishes different context limits for a model, adjust
|
- If Moonshot publishes different context limits for a model, adjust
|
||||||
`contextWindow` accordingly.
|
`contextWindow` accordingly.
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Refactor plan: exec host routing, node approvals, and headless runner"
|
|
||||||
read_when:
|
|
||||||
- Designing exec host routing or exec approvals
|
|
||||||
- Implementing node runner + UI IPC
|
|
||||||
- Adding exec host security modes and slash commands
|
|
||||||
---
|
|
||||||
|
|
||||||
# Exec host refactor plan
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**.
|
|
||||||
- Keep defaults **safe**: no cross-host execution unless explicitly enabled.
|
|
||||||
- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC.
|
|
||||||
- Provide **per-agent** policy, allowlist, ask mode, and node binding.
|
|
||||||
- Support **ask modes** that work *with* or *without* allowlists.
|
|
||||||
- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
- No legacy allowlist migration or legacy schema support.
|
|
||||||
- No PTY/streaming for node exec (aggregated output only).
|
|
||||||
- No new network layer beyond the existing Bridge + Gateway.
|
|
||||||
|
|
||||||
## Decisions (locked)
|
|
||||||
- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed).
|
|
||||||
- **Elevation:** keep `/elevated` as an alias for gateway full access.
|
|
||||||
- **Ask default:** `on-miss`.
|
|
||||||
- **Approvals store:** `~/.clawdbot/exec-approvals.json` (JSON, no legacy migration).
|
|
||||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
|
||||||
- **Node identity:** use existing `nodeId`.
|
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
|
||||||
|
|
||||||
## Key concepts
|
|
||||||
### Host
|
|
||||||
- `sandbox`: Docker exec (current behavior).
|
|
||||||
- `gateway`: exec on gateway host.
|
|
||||||
- `node`: exec on node runner via Bridge (`system.run`).
|
|
||||||
|
|
||||||
### Security mode
|
|
||||||
- `deny`: always block.
|
|
||||||
- `allowlist`: allow only matches.
|
|
||||||
- `full`: allow everything (equivalent to elevated).
|
|
||||||
|
|
||||||
### Ask mode
|
|
||||||
- `off`: never ask.
|
|
||||||
- `on-miss`: ask only when allowlist does not match.
|
|
||||||
- `always`: ask every time.
|
|
||||||
|
|
||||||
Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`.
|
|
||||||
|
|
||||||
### Policy resolution (per exec)
|
|
||||||
1) Resolve `exec.host` (tool param → agent override → global default).
|
|
||||||
2) Resolve `exec.security` and `exec.ask` (same precedence).
|
|
||||||
3) If host is `sandbox`, proceed with local sandbox exec.
|
|
||||||
4) If host is `gateway` or `node`, apply security + ask policy on that host.
|
|
||||||
|
|
||||||
## Default safety
|
|
||||||
- Default `exec.host = sandbox`.
|
|
||||||
- Default `exec.security = deny` for `gateway` and `node`.
|
|
||||||
- Default `exec.ask = on-miss` (only relevant if security allows).
|
|
||||||
- If no node binding is set, **agent may target any node**, but only if policy allows it.
|
|
||||||
|
|
||||||
## Config surface
|
|
||||||
### Tool parameters
|
|
||||||
- `exec.host` (optional): `sandbox | gateway | node`.
|
|
||||||
- `exec.security` (optional): `deny | allowlist | full`.
|
|
||||||
- `exec.ask` (optional): `off | on-miss | always`.
|
|
||||||
- `exec.node` (optional): node id/name to use when `host=node`.
|
|
||||||
|
|
||||||
### Config keys (global)
|
|
||||||
- `tools.exec.host`
|
|
||||||
- `tools.exec.security`
|
|
||||||
- `tools.exec.ask`
|
|
||||||
- `tools.exec.node` (default node binding)
|
|
||||||
|
|
||||||
### Config keys (per agent)
|
|
||||||
- `agents.list[].tools.exec.host`
|
|
||||||
- `agents.list[].tools.exec.security`
|
|
||||||
- `agents.list[].tools.exec.ask`
|
|
||||||
- `agents.list[].tools.exec.node`
|
|
||||||
|
|
||||||
### Alias
|
|
||||||
- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session.
|
|
||||||
- `/elevated off` = restore previous exec settings for the agent session.
|
|
||||||
|
|
||||||
## Approvals store (JSON)
|
|
||||||
Path: `~/.clawdbot/exec-approvals.json`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Local policy + allowlists for the **execution host** (gateway or node runner).
|
|
||||||
- Ask fallback when no UI is available.
|
|
||||||
- IPC credentials for UI clients.
|
|
||||||
|
|
||||||
Proposed schema (v1):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"socket": {
|
|
||||||
"path": "~/.clawdbot/exec-approvals.sock",
|
|
||||||
"token": "base64-opaque-token"
|
|
||||||
},
|
|
||||||
"defaults": {
|
|
||||||
"security": "deny",
|
|
||||||
"ask": "on-miss",
|
|
||||||
"askFallback": "deny"
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"agent-id-1": {
|
|
||||||
"security": "allowlist",
|
|
||||||
"ask": "on-miss",
|
|
||||||
"allowlist": [
|
|
||||||
{
|
|
||||||
"pattern": "~/Projects/**/bin/rg",
|
|
||||||
"lastUsedAt": 0,
|
|
||||||
"lastUsedCommand": "rg -n TODO",
|
|
||||||
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Notes:
|
|
||||||
- No legacy allowlist formats.
|
|
||||||
- `askFallback` applies only when `ask` is required and no UI is reachable.
|
|
||||||
- File permissions: `0600`.
|
|
||||||
|
|
||||||
## Runner service (headless)
|
|
||||||
### Role
|
|
||||||
- Enforce `exec.security` + `exec.ask` locally.
|
|
||||||
- Execute system commands and return output.
|
|
||||||
- Emit Bridge events for exec lifecycle (optional but recommended).
|
|
||||||
|
|
||||||
### Service lifecycle
|
|
||||||
- Launchd/daemon on macOS; system service on Linux/Windows.
|
|
||||||
- Approvals JSON is local to the execution host.
|
|
||||||
- UI hosts a local Unix socket; runners connect on demand.
|
|
||||||
|
|
||||||
## UI integration (macOS app)
|
|
||||||
### IPC
|
|
||||||
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
|
||||||
- Runner connects and sends an approval request; UI responds with a decision.
|
|
||||||
- Token stored in `exec-approvals.json`.
|
|
||||||
|
|
||||||
### Ask flow
|
|
||||||
1) Runner receives `system.run` from gateway.
|
|
||||||
2) If ask required, runner connects to the socket and sends a prompt request.
|
|
||||||
3) UI shows dialog; returns decision.
|
|
||||||
4) Runner enforces decision and proceeds.
|
|
||||||
|
|
||||||
If UI missing:
|
|
||||||
- Apply `askFallback` (`deny|allowlist|full`).
|
|
||||||
|
|
||||||
## Node identity + binding
|
|
||||||
- Use existing `nodeId` from Bridge pairing.
|
|
||||||
- Binding model:
|
|
||||||
- `tools.exec.node` restricts the agent to a specific node.
|
|
||||||
- If unset, agent can pick any node (policy still enforces defaults).
|
|
||||||
- Node selection resolution:
|
|
||||||
- `nodeId` exact match
|
|
||||||
- `displayName` (normalized)
|
|
||||||
- `remoteIp`
|
|
||||||
- `nodeId` prefix (>= 6 chars)
|
|
||||||
|
|
||||||
## Eventing
|
|
||||||
### Who sees events
|
|
||||||
- System events are **per session** and shown to the agent on the next prompt.
|
|
||||||
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
|
||||||
|
|
||||||
### Event text
|
|
||||||
- `Exec started (host=node, node=<id>, id=<runId>)`
|
|
||||||
- `Exec finished (exit=<code>, tail=<...>)`
|
|
||||||
- `Exec denied (policy=<...>, reason=<...>)`
|
|
||||||
|
|
||||||
### Transport
|
|
||||||
Option A (recommended):
|
|
||||||
- Runner sends Bridge `event` frames `exec.started` / `exec.finished`.
|
|
||||||
- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`.
|
|
||||||
|
|
||||||
Option B:
|
|
||||||
- Gateway `exec` tool handles lifecycle directly (synchronous only).
|
|
||||||
|
|
||||||
## Exec flows
|
|
||||||
### Sandbox host
|
|
||||||
- Existing `exec` behavior (Docker or host when unsandboxed).
|
|
||||||
- PTY supported in non-sandbox mode only.
|
|
||||||
|
|
||||||
### Gateway host
|
|
||||||
- Gateway process executes on its own machine.
|
|
||||||
- Enforces local `exec-approvals.json` (security/ask/allowlist).
|
|
||||||
|
|
||||||
### Node host
|
|
||||||
- Gateway calls `node.invoke` with `system.run`.
|
|
||||||
- Runner enforces local approvals.
|
|
||||||
- Runner returns aggregated stdout/stderr.
|
|
||||||
- Optional Bridge events for start/finish/deny.
|
|
||||||
|
|
||||||
## Output caps
|
|
||||||
- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events.
|
|
||||||
- Truncate with a clear suffix (e.g., `"… (truncated)"`).
|
|
||||||
|
|
||||||
## Slash commands
|
|
||||||
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
|
|
||||||
- Per-agent, per-session overrides; non-persistent unless saved via config.
|
|
||||||
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
|
|
||||||
|
|
||||||
## Cross-platform story
|
|
||||||
- The runner service is the portable execution target.
|
|
||||||
- UI is optional; if missing, `askFallback` applies.
|
|
||||||
- Windows/Linux support the same approvals JSON + socket protocol.
|
|
||||||
|
|
||||||
## Implementation phases
|
|
||||||
### Phase 1: config + exec routing
|
|
||||||
- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`.
|
|
||||||
- Update tool plumbing to respect `exec.host`.
|
|
||||||
- Add `/exec` slash command and keep `/elevated` alias.
|
|
||||||
|
|
||||||
### Phase 2: approvals store + gateway enforcement
|
|
||||||
- Implement `exec-approvals.json` reader/writer.
|
|
||||||
- Enforce allowlist + ask modes for `gateway` host.
|
|
||||||
- Add output caps.
|
|
||||||
|
|
||||||
### Phase 3: node runner enforcement
|
|
||||||
- Update node runner to enforce allowlist + ask.
|
|
||||||
- Add Unix socket prompt bridge to macOS app UI.
|
|
||||||
- Wire `askFallback`.
|
|
||||||
|
|
||||||
### Phase 4: events
|
|
||||||
- Add node → gateway Bridge events for exec lifecycle.
|
|
||||||
- Map to `enqueueSystemEvent` for agent prompts.
|
|
||||||
|
|
||||||
### Phase 5: UI polish
|
|
||||||
- Mac app: allowlist editor, per-agent switcher, ask policy UI.
|
|
||||||
- Node binding controls (optional).
|
|
||||||
|
|
||||||
## Testing plan
|
|
||||||
- Unit tests: allowlist matching (glob + case-insensitive).
|
|
||||||
- Unit tests: policy resolution precedence (tool param → agent override → global).
|
|
||||||
- Integration tests: node runner deny/allow/ask flows.
|
|
||||||
- Bridge event tests: node event → system event routing.
|
|
||||||
|
|
||||||
## Open risks
|
|
||||||
- UI unavailability: ensure `askFallback` is respected.
|
|
||||||
- Long-running commands: rely on timeout + output caps.
|
|
||||||
- Multi-node ambiguity: error unless node binding or explicit node param.
|
|
||||||
|
|
||||||
## Related docs
|
|
||||||
- [Exec tool](/tools/exec)
|
|
||||||
- [Exec approvals](/tools/exec-approvals)
|
|
||||||
- [Nodes](/nodes)
|
|
||||||
- [Elevated mode](/tools/elevated)
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
---
|
|
||||||
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,12 +10,6 @@ read_when:
|
|||||||
|
|
||||||
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
|
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**
|
1) **Version & metadata**
|
||||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||||
@@ -26,7 +20,6 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
2) **Build & artifacts**
|
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).
|
- [ ] 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/`).
|
- [ ] `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).
|
- [ ] 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**
|
3) **Changelog & docs**
|
||||||
@@ -58,7 +51,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- [ ] Confirm git status is clean; commit and push as needed.
|
- [ ] Confirm git status is clean; commit and push as needed.
|
||||||
- [ ] `npm login` (verify 2FA) if needed.
|
- [ ] `npm login` (verify 2FA) if needed.
|
||||||
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
||||||
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||||
|
|
||||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
### 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.
|
- **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.
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
|
|||||||
|
|
||||||
Rules of thumb:
|
Rules of thumb:
|
||||||
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
||||||
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
|
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
|
||||||
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
|
|
||||||
|
|
||||||
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -160,11 +160,7 @@ Example:
|
|||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
reset: {
|
idleMinutes: 10080
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 10080
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-9
@@ -880,19 +880,14 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
|||||||
|
|
||||||
### Do sessions reset automatically if I never send `/new`?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
|
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||||
You can also add an idle window; when both daily and idle resets are configured,
|
message starts a fresh session id for that chat key. This does not delete
|
||||||
whichever expires first starts a new session id on the next message. This does
|
transcripts — it just starts a new session.
|
||||||
not delete transcripts — it just starts a new session.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
reset: {
|
idleMinutes: 240
|
||||||
mode: "daily",
|
|
||||||
atHour: 4,
|
|
||||||
idleMinutes: 240
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||||
- More detail: [Synthetic](/providers/synthetic)
|
- More detail: [Synthetic](/providers/synthetic)
|
||||||
- **Moonshot (Kimi K2)**: config is auto-written.
|
- **Moonshot (Kimi K2)**: config is auto-written.
|
||||||
- **Kimi Code**: config is auto-written.
|
- More detail: [Moonshot AI](/providers/moonshot)
|
||||||
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
|
||||||
- **Skip**: no auth configured yet.
|
- **Skip**: no auth configured yet.
|
||||||
- Pick a default model from detected options (or enter provider/model manually).
|
- 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.
|
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||||
@@ -293,7 +292,6 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
|
|||||||
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
|
||||||
- `gateway.*` (mode, bind, auth, tailscale)
|
- `gateway.*` (mode, bind, auth, tailscale)
|
||||||
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
|
- `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`
|
- `skills.install.nodeManager`
|
||||||
- `wizard.lastRunAt`
|
- `wizard.lastRunAt`
|
||||||
- `wizard.lastRunVersion`
|
- `wizard.lastRunVersion`
|
||||||
|
|||||||
@@ -290,11 +290,6 @@ 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).
|
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)
|
## 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):
|
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
|
||||||
|
|||||||
+2
-2
@@ -42,13 +42,13 @@ Use these in chat:
|
|||||||
|
|
||||||
- `/status` → **emoji‑rich status card** with the session model, context usage,
|
- `/status` → **emoji‑rich status card** with the session model, context usage,
|
||||||
last response input/output tokens, and **estimated cost** (API key only).
|
last response input/output tokens, and **estimated cost** (API key only).
|
||||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
- `/cost on|off` → appends a **per-response usage line** to every reply.
|
||||||
- Persists per session (stored as `responseUsage`).
|
- Persists per session (stored as `responseUsage`).
|
||||||
- OAuth auth **hides cost** (tokens only).
|
- OAuth auth **hides cost** (tokens only).
|
||||||
|
|
||||||
Other surfaces:
|
Other surfaces:
|
||||||
|
|
||||||
- **TUI/Web TUI:** `/status` + `/usage` are supported.
|
- **TUI/Web TUI:** `/status` + `/cost` are supported.
|
||||||
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
|
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
|
||||||
provider quota windows (not per-response costs).
|
provider quota windows (not per-response costs).
|
||||||
|
|
||||||
|
|||||||
+15
-5
@@ -6,8 +6,9 @@ read_when:
|
|||||||
# Elevated Mode (/elevated directives)
|
# Elevated Mode (/elevated directives)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
|
- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved.
|
||||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host.
|
||||||
|
- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
|
||||||
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
|
||||||
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
- Only `on|off` are accepted; anything else returns a hint and does not change state.
|
||||||
|
|
||||||
@@ -16,9 +17,18 @@ read_when:
|
|||||||
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
|
||||||
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
- **Inline directive**: `/elevated on` inside a message applies to that message only.
|
||||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
|
||||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
|
||||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||||
|
- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Sandbox on: `/elevated on` runs that `exec` command on the host.
|
||||||
|
- Sandbox off: `/elevated on` does not change execution (already on host).
|
||||||
|
|
||||||
|
## When elevated matters
|
||||||
|
- Only impacts `exec` when the agent is running sandboxed (it drops the sandbox for that command).
|
||||||
|
- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status.
|
||||||
|
|
||||||
## Resolution order
|
## Resolution order
|
||||||
1. Inline directive on the message (applies only to that message).
|
1. Inline directive on the message (applies only to that message).
|
||||||
@@ -28,7 +38,7 @@ read_when:
|
|||||||
## Setting a session default
|
## Setting a session default
|
||||||
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
|
||||||
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
|
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
|
||||||
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
|
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state.
|
||||||
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
|
||||||
|
|
||||||
## Availability + allowlists
|
## Availability + allowlists
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Exec approvals, allowlists, and sandbox escape prompts"
|
|
||||||
read_when:
|
|
||||||
- Configuring exec approvals or allowlists
|
|
||||||
- Implementing exec approval UX in the macOS app
|
|
||||||
- Reviewing sandbox escape prompts and implications
|
|
||||||
---
|
|
||||||
|
|
||||||
# Exec approvals
|
|
||||||
|
|
||||||
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
|
||||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
|
||||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
|
||||||
Exec approvals are **in addition** to tool policy and elevated gating.
|
|
||||||
|
|
||||||
If the companion app UI is **not available**, any request that requires a prompt is
|
|
||||||
resolved by the **ask fallback** (default: deny).
|
|
||||||
|
|
||||||
## Where it applies
|
|
||||||
|
|
||||||
Exec approvals are enforced locally on the execution host:
|
|
||||||
- **gateway host** → `clawdbot` process on the gateway machine
|
|
||||||
- **node host** → node runner (macOS companion app or headless node)
|
|
||||||
|
|
||||||
## Settings and storage
|
|
||||||
|
|
||||||
Approvals live in a local JSON file:
|
|
||||||
|
|
||||||
`~/.clawdbot/exec-approvals.json`
|
|
||||||
|
|
||||||
Example schema:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"socket": {
|
|
||||||
"path": "~/.clawdbot/exec-approvals.sock",
|
|
||||||
"token": "base64url-token"
|
|
||||||
},
|
|
||||||
"defaults": {
|
|
||||||
"security": "deny",
|
|
||||||
"ask": "on-miss",
|
|
||||||
"askFallback": "deny",
|
|
||||||
"autoAllowSkills": false
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"main": {
|
|
||||||
"security": "allowlist",
|
|
||||||
"ask": "on-miss",
|
|
||||||
"askFallback": "deny",
|
|
||||||
"autoAllowSkills": true,
|
|
||||||
"allowlist": [
|
|
||||||
{
|
|
||||||
"pattern": "~/Projects/**/bin/rg",
|
|
||||||
"lastUsedAt": 1737150000000,
|
|
||||||
"lastUsedCommand": "rg -n TODO",
|
|
||||||
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Policy knobs
|
|
||||||
|
|
||||||
### Security (`exec.security`)
|
|
||||||
- **deny**: block all host exec requests.
|
|
||||||
- **allowlist**: allow only allowlisted commands.
|
|
||||||
- **full**: allow everything (equivalent to elevated).
|
|
||||||
|
|
||||||
### Ask (`exec.ask`)
|
|
||||||
- **off**: never prompt.
|
|
||||||
- **on-miss**: prompt only when allowlist does not match.
|
|
||||||
- **always**: prompt on every command.
|
|
||||||
|
|
||||||
### Ask fallback (`askFallback`)
|
|
||||||
If a prompt is required but no UI is reachable, fallback decides:
|
|
||||||
- **deny**: block.
|
|
||||||
- **allowlist**: allow only if allowlist matches.
|
|
||||||
- **full**: allow.
|
|
||||||
|
|
||||||
## Allowlist (per agent)
|
|
||||||
|
|
||||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
|
||||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `~/Projects/**/bin/bird`
|
|
||||||
- `~/.local/bin/*`
|
|
||||||
- `/opt/homebrew/bin/rg`
|
|
||||||
|
|
||||||
Each allowlist entry tracks:
|
|
||||||
- **last used** timestamp
|
|
||||||
- **last used command**
|
|
||||||
- **last resolved path**
|
|
||||||
|
|
||||||
## Auto-allow skill CLIs
|
|
||||||
|
|
||||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
|
||||||
are treated as allowlisted (node hosts only). Disable this if you want strict
|
|
||||||
manual allowlists.
|
|
||||||
|
|
||||||
## Approval flow
|
|
||||||
|
|
||||||
When a prompt is required, the companion app displays a confirmation dialog with:
|
|
||||||
- command + args
|
|
||||||
- cwd
|
|
||||||
- agent id
|
|
||||||
- resolved executable path
|
|
||||||
- host + policy metadata
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
- **Allow once** → run now
|
|
||||||
- **Always allow** → add to allowlist + run
|
|
||||||
- **Deny** → block
|
|
||||||
|
|
||||||
## System events
|
|
||||||
|
|
||||||
Exec lifecycle is surfaced as system messages:
|
|
||||||
- `exec.started`
|
|
||||||
- `exec.finished`
|
|
||||||
- `exec.denied`
|
|
||||||
|
|
||||||
These are posted to the agent’s session after the node reports the event.
|
|
||||||
|
|
||||||
## Implications
|
|
||||||
|
|
||||||
- **full** is powerful; prefer allowlists when possible.
|
|
||||||
- **ask** keeps you in the loop while still allowing fast approvals.
|
|
||||||
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- [Exec tool](/tools/exec)
|
|
||||||
- [Elevated mode](/tools/elevated)
|
|
||||||
- [Skills](/tools/skills)
|
|
||||||
+3
-33
@@ -14,47 +14,17 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
|||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `command` (required)
|
- `command` (required)
|
||||||
- `workdir` (defaults to cwd)
|
|
||||||
- `env` (key/value overrides)
|
|
||||||
- `yieldMs` (default 10000): auto-background after delay
|
- `yieldMs` (default 10000): auto-background after delay
|
||||||
- `background` (bool): background immediately
|
- `background` (bool): background immediately
|
||||||
- `timeout` (seconds, default 1800): kill on expiry
|
- `timeout` (seconds, default 1800): kill on expiry
|
||||||
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
|
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
|
||||||
- `host` (`sandbox | gateway | node`): where to execute
|
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
|
||||||
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
|
||||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||||
- `node` (string): node id/name for `host=node`
|
|
||||||
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `host` defaults to `sandbox`.
|
|
||||||
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
|
||||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
|
||||||
- `node` requires a paired node (macOS companion app).
|
|
||||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
- `tools.exec.host` (default: `sandbox`)
|
|
||||||
- `tools.exec.security` (default: `deny`)
|
|
||||||
- `tools.exec.ask` (default: `on-miss`)
|
|
||||||
- `tools.exec.node` (default: unset)
|
|
||||||
|
|
||||||
## Session overrides (`/exec`)
|
|
||||||
|
|
||||||
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
|
||||||
Send `/exec` with no arguments to show the current values.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exec approvals (macOS app)
|
|
||||||
|
|
||||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
|
||||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -169,19 +169,14 @@ Core parameters:
|
|||||||
- `background` (immediate background)
|
- `background` (immediate background)
|
||||||
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
||||||
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
|
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
|
||||||
- `host` (`sandbox | gateway | node`)
|
|
||||||
- `security` (`deny | allowlist | full`)
|
|
||||||
- `ask` (`off | on-miss | always`)
|
|
||||||
- `node` (node id/name for `host=node`)
|
|
||||||
- Need a real TTY? Set `pty: true`.
|
- Need a real TTY? Set `pty: true`.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||||
- Use `process` to poll/log/write/kill/clear background sessions.
|
- Use `process` to poll/log/write/kill/clear background sessions.
|
||||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
- 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 is an alias for `host=gateway` + `security=full`.
|
- `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).
|
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
|
||||||
|
|
||||||
### `process`
|
### `process`
|
||||||
Manage background exec sessions.
|
Manage background exec sessions.
|
||||||
|
|||||||
Loaded 100 of 739 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user