Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01aa7eba21 | ||
|
|
9479fded34 |
No files matched your search
@@ -57,4 +57,3 @@ apps/ios/*.mobileprovision
|
|||||||
.vscode/
|
.vscode/
|
||||||
IDENTITY.md
|
IDENTITY.md
|
||||||
USER.md
|
USER.md
|
||||||
.tgz
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "Peekaboo"]
|
||||||
|
path = Peekaboo
|
||||||
|
url = https://github.com/steipete/Peekaboo.git
|
||||||
|
branch = main
|
||||||
@@ -1 +1 @@
|
|||||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": [
|
|
||||||
"unicorn",
|
|
||||||
"typescript",
|
|
||||||
"oxc"
|
|
||||||
],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error"
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/oxlintrc",
|
||||||
|
"extends": ["recommended"]
|
||||||
|
}
|
||||||
@@ -54,7 +54,6 @@
|
|||||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
|
||||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||||
- When working on an issue: reference the issue in the changelog entry.
|
- When working on an issue: reference the issue in the changelog entry.
|
||||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
||||||
@@ -73,7 +72,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`).
|
||||||
@@ -109,7 +107,6 @@
|
|||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
@@ -118,7 +115,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.
|
||||||
|
|||||||
+1
-261
@@ -1,248 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
## 2026.1.15 (unreleased)
|
||||||
|
|
||||||
## 2026.1.18-4
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
|
||||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
|
||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
|
|
||||||
- Matrix: avoid redeclaring `allowFrom` in the monitor provider. (#1176) — thanks @sibbl.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
|
||||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
|
||||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
|
||||||
- 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.
|
|
||||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
|
||||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
|
||||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
|
||||||
- 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.
|
|
||||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
|
||||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
|
||||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
|
||||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
|
||||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
|
||||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
|
||||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
|
||||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
|
||||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
|
||||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
|
||||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
|
||||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
|
||||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
|
||||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
|
||||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
|
||||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
|
||||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
|
||||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
|
||||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
|
||||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
|
||||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
|
||||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
|
||||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
|
||||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
|
||||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
|
||||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
|
||||||
- UI: allow deleting sessions from the Control UI.
|
|
||||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
|
||||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
|
||||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
|
||||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
|
||||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
|
||||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
|
||||||
- Messages: refresh live directory cache results when resolving targets.
|
|
||||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
|
||||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
|
||||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
|
||||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
|
||||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
|
||||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
|
||||||
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
|
|
||||||
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
|
|
||||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
|
||||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
|
||||||
- Tools: include provider/session context in elevated exec denial errors.
|
|
||||||
- Tools: normalize exec tool alias naming in tool error logs.
|
|
||||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
|
||||||
- Logging: prefix nested agent output with session/run/channel context.
|
|
||||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
|
||||||
- Telegram: split long captions into follow-up messages.
|
|
||||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
|
||||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
|
||||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
|
||||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
|
||||||
- Sessions: preserve overrides on `/new` reset.
|
|
||||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
|
||||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
|
||||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
|
||||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
|
||||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
|
||||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
|
||||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
|
||||||
- Messages: honor message tool channel when deduping sends.
|
|
||||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
|
||||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
|
||||||
- Sessions: repair orphaned user turns before embedded prompts.
|
|
||||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
|
||||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
|
||||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
|
||||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
|
||||||
- Security: redact sensitive text in gateway WS logs.
|
|
||||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
|
||||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
|
||||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
|
||||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
|
||||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
|
||||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
|
||||||
- WhatsApp: include `linked` field in `describeAccount`.
|
|
||||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
|
||||||
- Agents: hide the image tool when the primary model already supports images.
|
|
||||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
|
||||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
|
||||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
|
||||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
|
||||||
- iMessage: avoid RPC restart loops.
|
|
||||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
|
|
||||||
- CLI: auto-update global installs when installed via a package manager.
|
|
||||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
|
||||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
|
||||||
- Security: bump `tar` to 7.5.3.
|
|
||||||
- Models: align ZAI thinking toggles.
|
|
||||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
|
||||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
|
||||||
- Skills: fix skills watcher ignored list typing (tsc).
|
|
||||||
|
|
||||||
## 2026.1.15
|
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
@@ -253,15 +11,10 @@ Docs: https://docs.clawd.bot
|
|||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||||
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
|
|
||||||
- Telegram: default reaction notifications to own.
|
|
||||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||||
@@ -270,7 +23,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: show provider/model labels for the active session and default model.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||||
- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel.
|
|
||||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||||
@@ -295,17 +47,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
|
|
||||||
- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped.
|
|
||||||
- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos.
|
|
||||||
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
|
|
||||||
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
|
|
||||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
|
||||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
|
||||||
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
|
|
||||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
|
||||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
|
||||||
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||||
@@ -317,7 +58,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||||
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
|
|
||||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
|
||||||
ENV CLAWDBOT_PREFER_PNPM=1
|
|
||||||
RUN pnpm ui:install
|
RUN pnpm ui:install
|
||||||
RUN pnpm ui:build
|
RUN pnpm ui:build
|
||||||
|
|
||||||
|
|||||||
Submodule
+1
Submodule Peekaboo added at 5c195f5e46.
@@ -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)
|
||||||
|
|
||||||
@@ -474,25 +474,23 @@ Core contributors:
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
||||||
<a href="https://github.com/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/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/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/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||||
<a href="https://github.com/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/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||||
<a href="https://github.com/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/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||||
<a href="https://github.com/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/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
||||||
<a href="https://github.com/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/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
||||||
<a href="https://github.com/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/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||||
<a href="https://github.com/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||||
<a href="https://github.com/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/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
||||||
<a href="https://github.com/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||||
<a href="https://github.com/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
||||||
<a href="https://github.com/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||||
<a href="https://github.com/search?q=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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
||||||
<a href="https://github.com/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/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||||
<a href="https://github.com/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/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||||
<a href="https://github.com/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/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||||
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
|
||||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
.package(path: "../Peekaboo/Commander"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
+33
-97
@@ -2,103 +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>
|
|
||||||
<title>2026.1.15</title>
|
|
||||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5998</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
|
||||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
|
||||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
|
||||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
|
||||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
|
||||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
|
||||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
|
||||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
|
||||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
|
||||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
|
||||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
|
||||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
|
||||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
|
||||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
|
||||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
|
||||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
|
||||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
|
||||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
|
||||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
|
||||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
|
||||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
|
||||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
|
||||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
|
||||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
|
||||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
|
||||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
|
||||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
|
||||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
|
||||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
|
||||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
|
||||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
|
||||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
|
||||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
|
||||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
|
||||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
|
||||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
|
||||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.14-1</title>
|
<title>2026.1.14-1</title>
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||||
@@ -271,5 +174,38 @@
|
|||||||
]]></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>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.11-3</title>
|
||||||
|
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>5212</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||||
|
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
+73
-28
@@ -1,24 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "axorcist",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/steipete/AXorcist.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
|
|
||||||
"version" : "0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "commander",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/steipete/Commander.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
|
||||||
"version" : "0.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "elevenlabskit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -28,6 +10,15 @@
|
|||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "eventsource",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/mattt/eventsource.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "menubarextraaccess",
|
"identity" : "menubarextraaccess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -37,15 +28,6 @@
|
|||||||
"version" : "1.2.2"
|
"version" : "1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "peekaboo",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "sparkle",
|
"identity" : "sparkle",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -64,6 +46,33 @@
|
|||||||
"version" : "1.2.1"
|
"version" : "1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-asn1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-asn1.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
||||||
|
"version" : "1.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-async-algorithms",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-concurrency-extras",
|
"identity" : "swift-concurrency-extras",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -73,6 +82,24 @@
|
|||||||
"version" : "1.3.2"
|
"version" : "1.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-configuration",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-configuration",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-crypto",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-crypto.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
|
||||||
|
"version" : "4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -91,6 +118,24 @@
|
|||||||
"version" : "1.1.1"
|
"version" : "1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-sdk",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
|
||||||
|
"version" : "0.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-service-lifecycle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-server/swift-service-lifecycle",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
||||||
|
"version" : "2.9.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-subprocess",
|
"identity" : "swift-subprocess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
|
||||||
.package(path: "../shared/ClawdbotKit"),
|
.package(path: "../shared/ClawdbotKit"),
|
||||||
.package(path: "../../Swabble"),
|
.package(path: "../../Swabble"),
|
||||||
|
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||||
|
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -60,8 +61,8 @@ let package = Package(
|
|||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"Resources/Info.plist",
|
"Resources/Info.plist",
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -264,8 +257,30 @@ final class AppState {
|
|||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
|
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let configMode: ConnectionMode? = switch configModeRaw {
|
||||||
|
case "local":
|
||||||
|
.local
|
||||||
|
case "remote":
|
||||||
|
.remote
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
let configHasRemoteUrl = !(configRemoteUrl?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.isEmpty ?? true)
|
||||||
|
|
||||||
|
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
|
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||||
|
configMode
|
||||||
|
} else if configHasRemoteUrl {
|
||||||
|
.remote
|
||||||
|
} else if let storedMode {
|
||||||
|
ConnectionMode(rawValue: storedMode) ?? .local
|
||||||
|
} else {
|
||||||
|
onboardingSeen ? .local : .unconfigured
|
||||||
|
}
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
@@ -281,8 +296,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)
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ConfigSchemaForm: View {
|
|
||||||
@Bindable var store: ChannelsStore
|
|
||||||
let schema: ConfigSchemaNode
|
|
||||||
let path: ConfigPath
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
self.renderNode(self.schema, path: self.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
|
||||||
let storedValue = self.store.configValue(at: path)
|
|
||||||
let value = storedValue ?? schema.explicitDefault
|
|
||||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
|
||||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
|
||||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
|
||||||
|
|
||||||
if !variants.isEmpty {
|
|
||||||
let nonNull = variants.filter { !$0.isNullSchema }
|
|
||||||
if nonNull.count == 1, let only = nonNull.first {
|
|
||||||
return self.renderNode(only, path: path)
|
|
||||||
}
|
|
||||||
let literals = nonNull.compactMap(\.literalValue)
|
|
||||||
if !literals.isEmpty, literals.count == nonNull.count {
|
|
||||||
return AnyView(
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
|
||||||
if let help {
|
|
||||||
Text(help)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Picker(
|
|
||||||
"",
|
|
||||||
selection: self.enumBinding(
|
|
||||||
path,
|
|
||||||
options: literals,
|
|
||||||
defaultValue: schema.explicitDefault))
|
|
||||||
{
|
|
||||||
Text("Select…").tag(-1)
|
|
||||||
ForEach(literals.indices, id: \ .self) { index in
|
|
||||||
Text(String(describing: literals[index])).tag(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch schema.schemaType {
|
|
||||||
case "object":
|
|
||||||
return AnyView(
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
if let label {
|
|
||||||
Text(label)
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
}
|
|
||||||
if let help {
|
|
||||||
Text(help)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
let properties = schema.properties
|
|
||||||
let sortedKeys = properties.keys.sorted { lhs, rhs in
|
|
||||||
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
|
|
||||||
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
|
|
||||||
if orderA != orderB { return orderA < orderB }
|
|
||||||
return lhs < rhs
|
|
||||||
}
|
|
||||||
ForEach(sortedKeys, id: \ .self) { key in
|
|
||||||
if let child = properties[key] {
|
|
||||||
self.renderNode(child, path: path + [.key(key)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if schema.allowsAdditionalProperties {
|
|
||||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
case "array":
|
|
||||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
|
||||||
case "boolean":
|
|
||||||
return AnyView(
|
|
||||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
|
||||||
if let label { Text(label) } else { Text("Enabled") }
|
|
||||||
}
|
|
||||||
.help(help ?? ""))
|
|
||||||
case "number", "integer":
|
|
||||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
|
||||||
case "string":
|
|
||||||
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
|
|
||||||
default:
|
|
||||||
return AnyView(
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
|
||||||
Text("Unsupported field type.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func renderStringField(
|
|
||||||
_ schema: ConfigSchemaNode,
|
|
||||||
path: ConfigPath,
|
|
||||||
label: String?,
|
|
||||||
help: String?) -> some View
|
|
||||||
{
|
|
||||||
let hint = hintForPath(path, hints: store.configUiHints)
|
|
||||||
let placeholder = hint?.placeholder ?? ""
|
|
||||||
let sensitive = hint?.sensitive ?? isSensitivePath(path)
|
|
||||||
let defaultValue = schema.explicitDefault as? String
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
|
||||||
if let help {
|
|
||||||
Text(help)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
if let options = schema.enumValues {
|
|
||||||
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
|
|
||||||
Text("Select…").tag(-1)
|
|
||||||
ForEach(options.indices, id: \ .self) { index in
|
|
||||||
Text(String(describing: options[index])).tag(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
} else if sensitive {
|
|
||||||
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
} else {
|
|
||||||
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func renderNumberField(
|
|
||||||
_ schema: ConfigSchemaNode,
|
|
||||||
path: ConfigPath,
|
|
||||||
label: String?,
|
|
||||||
help: String?) -> some View
|
|
||||||
{
|
|
||||||
let defaultValue = (schema.explicitDefault as? Double)
|
|
||||||
?? (schema.explicitDefault as? Int).map(Double.init)
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
|
||||||
if let help {
|
|
||||||
Text(help)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
TextField(
|
|
||||||
"",
|
|
||||||
text: self.numberBinding(
|
|
||||||
path,
|
|
||||||
isInteger: schema.schemaType == "integer",
|
|
||||||
defaultValue: defaultValue))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func renderArray(
|
|
||||||
_ schema: ConfigSchemaNode,
|
|
||||||
path: ConfigPath,
|
|
||||||
value: Any?,
|
|
||||||
label: String?,
|
|
||||||
help: String?) -> some View
|
|
||||||
{
|
|
||||||
let items = value as? [Any] ?? []
|
|
||||||
let itemSchema = schema.items
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
|
||||||
if let help {
|
|
||||||
Text(help)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
ForEach(items.indices, id: \ .self) { index in
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
if let itemSchema {
|
|
||||||
self.renderNode(itemSchema, path: path + [.index(index)])
|
|
||||||
} else {
|
|
||||||
Text(String(describing: items[index]))
|
|
||||||
}
|
|
||||||
Button("Remove") {
|
|
||||||
var next = items
|
|
||||||
next.remove(at: index)
|
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Add") {
|
|
||||||
var next = items
|
|
||||||
if let itemSchema {
|
|
||||||
next.append(itemSchema.defaultValue)
|
|
||||||
} else {
|
|
||||||
next.append("")
|
|
||||||
}
|
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func renderAdditionalProperties(
|
|
||||||
_ schema: ConfigSchemaNode,
|
|
||||||
path: ConfigPath,
|
|
||||||
value: Any?) -> some View
|
|
||||||
{
|
|
||||||
if let additionalSchema = schema.additionalProperties {
|
|
||||||
let dict = value as? [String: Any] ?? [:]
|
|
||||||
let reserved = Set(schema.properties.keys)
|
|
||||||
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Extra entries")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
if extras.isEmpty {
|
|
||||||
Text("No extra entries yet.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(extras, id: \ .self) { key in
|
|
||||||
let itemPath: ConfigPath = path + [.key(key)]
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: 160)
|
|
||||||
self.renderNode(additionalSchema, path: itemPath)
|
|
||||||
Button("Remove") {
|
|
||||||
var next = dict
|
|
||||||
next.removeValue(forKey: key)
|
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Add") {
|
|
||||||
var next = dict
|
|
||||||
var index = 1
|
|
||||||
var key = "new-\(index)"
|
|
||||||
while next[key] != nil {
|
|
||||||
index += 1
|
|
||||||
key = "new-\(index)"
|
|
||||||
}
|
|
||||||
next[key] = additionalSchema.defaultValue
|
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
if let value = store.configValue(at: path) as? String { return value }
|
|
||||||
return defaultValue ?? ""
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
if let value = store.configValue(at: path) as? Bool { return value }
|
|
||||||
return defaultValue ?? false
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
self.store.updateConfigValue(path: path, value: newValue)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func numberBinding(
|
|
||||||
_ path: ConfigPath,
|
|
||||||
isInteger: Bool,
|
|
||||||
defaultValue: Double?) -> Binding<String>
|
|
||||||
{
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
|
||||||
guard let defaultValue else { return "" }
|
|
||||||
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty {
|
|
||||||
self.store.updateConfigValue(path: path, value: nil)
|
|
||||||
} else if let value = Double(trimmed) {
|
|
||||||
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enumBinding(
|
|
||||||
_ path: ConfigPath,
|
|
||||||
options: [Any],
|
|
||||||
defaultValue: Any?) -> Binding<Int>
|
|
||||||
{
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
let value = self.store.configValue(at: path) ?? defaultValue
|
|
||||||
guard let value else { return -1 }
|
|
||||||
return options.firstIndex { option in
|
|
||||||
String(describing: option) == String(describing: value)
|
|
||||||
} ?? -1
|
|
||||||
},
|
|
||||||
set: { index in
|
|
||||||
guard index >= 0, index < options.count else {
|
|
||||||
self.store.updateConfigValue(path: path, value: nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.store.updateConfigValue(path: path, value: options[index])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: { key },
|
|
||||||
set: { newValue in
|
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
guard trimmed != key else { return }
|
|
||||||
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
|
||||||
guard current[trimmed] == nil else { return }
|
|
||||||
var next = current
|
|
||||||
next[trimmed] = current[key]
|
|
||||||
next.removeValue(forKey: key)
|
|
||||||
self.store.updateConfigValue(path: path, value: next)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChannelConfigForm: View {
|
|
||||||
@Bindable var store: ChannelsStore
|
|
||||||
let channelId: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
|
||||||
} else {
|
|
||||||
Text("Schema unavailable for this channel.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension ChannelsSettings {
|
|
||||||
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
|
||||||
GroupBox(title) {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func channelHeaderActions(_ channel: ChannelItem) -> some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if channel.id == "whatsapp" {
|
|
||||||
Button("Logout") {
|
|
||||||
Task { await self.store.logoutWhatsApp() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.whatsappBusy)
|
|
||||||
}
|
|
||||||
|
|
||||||
if channel.id == "telegram" {
|
|
||||||
Button("Logout") {
|
|
||||||
Task { await self.store.logoutTelegram() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.telegramBusy)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task { await self.store.refresh(probe: true) }
|
|
||||||
} label: {
|
|
||||||
if self.store.isRefreshing {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Refresh")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.isRefreshing)
|
|
||||||
}
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
|
|
||||||
var whatsAppSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
self.formSection("Linking") {
|
|
||||||
if let message = self.store.whatsappLoginMessage {
|
|
||||||
Text(message)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.interpolation(.none)
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
Task { await self.store.startWhatsAppLogin(force: false) }
|
|
||||||
} label: {
|
|
||||||
if self.store.whatsappBusy {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Show QR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.store.whatsappBusy)
|
|
||||||
|
|
||||||
Button("Relink") {
|
|
||||||
Task { await self.store.startWhatsAppLogin(force: true) }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.whatsappBusy)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.configEditorSection(channelId: "whatsapp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func genericChannelSection(_ channel: ChannelItem) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
self.configEditorSection(channelId: channel.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func configEditorSection(channelId: String) -> some View {
|
|
||||||
self.formSection("Configuration") {
|
|
||||||
ChannelConfigForm(store: self.store, channelId: channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.configStatusMessage
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
Task { await self.store.saveConfigDraft() }
|
|
||||||
} label: {
|
|
||||||
if self.store.isSavingConfig {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.store.isSavingConfig || !self.store.configDirty)
|
|
||||||
|
|
||||||
Button("Reload") {
|
|
||||||
Task { await self.store.reloadConfigDraft() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.store.isSavingConfig)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var configStatusMessage: some View {
|
|
||||||
if let status = self.store.configStatus {
|
|
||||||
Text(status)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ChannelsSettings: View {
|
|
||||||
struct ChannelItem: Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let detailTitle: String
|
|
||||||
let systemImage: String
|
|
||||||
let sortOrder: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bindable var store: ChannelsStore
|
|
||||||
@State var selectedChannel: ChannelItem?
|
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
|
||||||
self.store = store
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import ClawdbotProtocol
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension ChannelsStore {
|
|
||||||
func loadConfigSchema() async {
|
|
||||||
guard !self.configSchemaLoading else { return }
|
|
||||||
self.configSchemaLoading = true
|
|
||||||
defer { self.configSchemaLoading = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .configSchema,
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 8000)
|
|
||||||
let schemaValue = res.schema.foundationValue
|
|
||||||
self.configSchema = ConfigSchemaNode(raw: schemaValue)
|
|
||||||
let hintValues = res.uihints.mapValues { $0.foundationValue }
|
|
||||||
self.configUiHints = decodeUiHints(hintValues)
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() async {
|
|
||||||
do {
|
|
||||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
|
||||||
method: .configGet,
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 10000)
|
|
||||||
self.configStatus = snap.valid == false
|
|
||||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
|
||||||
: nil
|
|
||||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
|
||||||
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
|
|
||||||
self.configDirty = false
|
|
||||||
self.configLoaded = true
|
|
||||||
|
|
||||||
self.applyUIConfig(snap)
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
|
||||||
let ui = snap.config?["ui"]?.dictionaryValue
|
|
||||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
|
||||||
}
|
|
||||||
|
|
||||||
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
|
|
||||||
guard let root = self.configSchema else { return nil }
|
|
||||||
return root.node(at: [.key("channels"), .key(channelId)])
|
|
||||||
}
|
|
||||||
|
|
||||||
func configValue(at path: ConfigPath) -> Any? {
|
|
||||||
if let value = valueAtPath(self.configDraft, path: path) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
guard path.count >= 2 else { return nil }
|
|
||||||
if case .key("channels") = path[0], case .key = path[1] {
|
|
||||||
let fallbackPath = Array(path.dropFirst())
|
|
||||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateConfigValue(path: ConfigPath, value: Any?) {
|
|
||||||
var root: Any = self.configDraft
|
|
||||||
setValue(&root, path: path, value: value)
|
|
||||||
self.configDraft = root as? [String: Any] ?? self.configDraft
|
|
||||||
self.configDirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveConfigDraft() async {
|
|
||||||
guard !self.isSavingConfig else { return }
|
|
||||||
self.isSavingConfig = true
|
|
||||||
defer { self.isSavingConfig = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await ConfigStore.save(self.configDraft)
|
|
||||||
await self.loadConfig()
|
|
||||||
} catch {
|
|
||||||
self.configStatus = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadConfigDraft() async {
|
|
||||||
await self.loadConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
|
||||||
var current: Any? = root
|
|
||||||
for segment in path {
|
|
||||||
switch segment {
|
|
||||||
case let .key(key):
|
|
||||||
guard let dict = current as? [String: Any] else { return nil }
|
|
||||||
current = dict[key]
|
|
||||||
case let .index(index):
|
|
||||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
|
||||||
current = array[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
|
||||||
guard let segment = path.first else { return }
|
|
||||||
switch segment {
|
|
||||||
case let .key(key):
|
|
||||||
var dict = root as? [String: Any] ?? [:]
|
|
||||||
if path.count == 1 {
|
|
||||||
if let value {
|
|
||||||
dict[key] = value
|
|
||||||
} else {
|
|
||||||
dict.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
root = dict
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var child = dict[key] ?? [:]
|
|
||||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
|
||||||
dict[key] = child
|
|
||||||
root = dict
|
|
||||||
case let .index(index):
|
|
||||||
var array = root as? [Any] ?? []
|
|
||||||
if index >= array.count {
|
|
||||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
|
||||||
}
|
|
||||||
if path.count == 1 {
|
|
||||||
if let value {
|
|
||||||
array[index] = value
|
|
||||||
} else if array.indices.contains(index) {
|
|
||||||
array.remove(at: index)
|
|
||||||
}
|
|
||||||
root = array
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var child = array[index]
|
|
||||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
|
||||||
array[index] = child
|
|
||||||
root = array
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cloneConfigValue(_ value: Any) -> Any {
|
|
||||||
guard JSONSerialization.isValidJSONObject(value) else { return value }
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: value, options: [])
|
|
||||||
return try JSONSerialization.jsonObject(with: data, options: [])
|
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,12 +384,15 @@ enum CommandResolver {
|
|||||||
let cliPath: String
|
let cliPath: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings(
|
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||||
defaults: UserDefaults = .standard,
|
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
let mode: AppState.ConnectionMode
|
||||||
{
|
if let modeRaw {
|
||||||
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
} else {
|
||||||
|
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||||
|
mode = seen ? .local : .unconfigured
|
||||||
|
}
|
||||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum ConfigPathSegment: Hashable {
|
|
||||||
case key(String)
|
|
||||||
case index(Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias ConfigPath = [ConfigPathSegment]
|
|
||||||
|
|
||||||
struct ConfigUiHint {
|
|
||||||
let label: String?
|
|
||||||
let help: String?
|
|
||||||
let order: Double?
|
|
||||||
let advanced: Bool?
|
|
||||||
let sensitive: Bool?
|
|
||||||
let placeholder: String?
|
|
||||||
|
|
||||||
init(raw: [String: Any]) {
|
|
||||||
self.label = raw["label"] as? String
|
|
||||||
self.help = raw["help"] as? String
|
|
||||||
if let order = raw["order"] as? Double {
|
|
||||||
self.order = order
|
|
||||||
} else if let orderInt = raw["order"] as? Int {
|
|
||||||
self.order = Double(orderInt)
|
|
||||||
} else {
|
|
||||||
self.order = nil
|
|
||||||
}
|
|
||||||
self.advanced = raw["advanced"] as? Bool
|
|
||||||
self.sensitive = raw["sensitive"] as? Bool
|
|
||||||
self.placeholder = raw["placeholder"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ConfigSchemaNode {
|
|
||||||
let raw: [String: Any]
|
|
||||||
|
|
||||||
init?(raw: Any) {
|
|
||||||
guard let dict = raw as? [String: Any] else { return nil }
|
|
||||||
self.raw = dict
|
|
||||||
}
|
|
||||||
|
|
||||||
var title: String? { self.raw["title"] as? String }
|
|
||||||
var description: String? { self.raw["description"] as? String }
|
|
||||||
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
|
|
||||||
var constValue: Any? { self.raw["const"] }
|
|
||||||
var explicitDefault: Any? { self.raw["default"] }
|
|
||||||
var requiredKeys: Set<String> {
|
|
||||||
Set((self.raw["required"] as? [String]) ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
var typeList: [String] {
|
|
||||||
if let type = self.raw["type"] as? String { return [type] }
|
|
||||||
if let types = self.raw["type"] as? [String] { return types }
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemaType: String? {
|
|
||||||
let filtered = self.typeList.filter { $0 != "null" }
|
|
||||||
if let first = filtered.first { return first }
|
|
||||||
return self.typeList.first
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNullSchema: Bool {
|
|
||||||
let types = self.typeList
|
|
||||||
return types.count == 1 && types.first == "null"
|
|
||||||
}
|
|
||||||
|
|
||||||
var properties: [String: ConfigSchemaNode] {
|
|
||||||
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
|
|
||||||
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var anyOf: [ConfigSchemaNode] {
|
|
||||||
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
|
|
||||||
return raw.compactMap { ConfigSchemaNode(raw: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var oneOf: [ConfigSchemaNode] {
|
|
||||||
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
|
|
||||||
return raw.compactMap { ConfigSchemaNode(raw: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var literalValue: Any? {
|
|
||||||
if let constValue { return constValue }
|
|
||||||
if let enumValues, enumValues.count == 1 { return enumValues[0] }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var items: ConfigSchemaNode? {
|
|
||||||
if let items = self.raw["items"] as? [Any], let first = items.first {
|
|
||||||
return ConfigSchemaNode(raw: first)
|
|
||||||
}
|
|
||||||
if let items = self.raw["items"] {
|
|
||||||
return ConfigSchemaNode(raw: items)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var additionalProperties: ConfigSchemaNode? {
|
|
||||||
if let additional = self.raw["additionalProperties"] as? [String: Any] {
|
|
||||||
return ConfigSchemaNode(raw: additional)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var allowsAdditionalProperties: Bool {
|
|
||||||
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
|
|
||||||
return self.additionalProperties != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultValue: Any {
|
|
||||||
if let value = self.raw["default"] { return value }
|
|
||||||
switch self.schemaType {
|
|
||||||
case "object":
|
|
||||||
return [String: Any]()
|
|
||||||
case "array":
|
|
||||||
return [Any]()
|
|
||||||
case "boolean":
|
|
||||||
return false
|
|
||||||
case "integer":
|
|
||||||
return 0
|
|
||||||
case "number":
|
|
||||||
return 0.0
|
|
||||||
case "string":
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func node(at path: ConfigPath) -> ConfigSchemaNode? {
|
|
||||||
var current: ConfigSchemaNode? = self
|
|
||||||
for segment in path {
|
|
||||||
guard let node = current else { return nil }
|
|
||||||
switch segment {
|
|
||||||
case let .key(key):
|
|
||||||
if node.schemaType == "object" {
|
|
||||||
if let next = node.properties[key] {
|
|
||||||
current = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if let additional = node.additionalProperties {
|
|
||||||
current = additional
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case .index:
|
|
||||||
guard node.schemaType == "array" else { return nil }
|
|
||||||
current = node.items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
|
|
||||||
raw.reduce(into: [:]) { result, entry in
|
|
||||||
if let hint = entry.value as? [String: Any] {
|
|
||||||
result[entry.key] = ConfigUiHint(raw: hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
|
|
||||||
let key = pathKey(path)
|
|
||||||
if let direct = hints[key] { return direct }
|
|
||||||
let segments = key.split(separator: ".").map(String.init)
|
|
||||||
for (hintKey, hint) in hints {
|
|
||||||
guard hintKey.contains("*") else { continue }
|
|
||||||
let hintSegments = hintKey.split(separator: ".").map(String.init)
|
|
||||||
guard hintSegments.count == segments.count else { continue }
|
|
||||||
var match = true
|
|
||||||
for (index, seg) in segments.enumerated() {
|
|
||||||
let hintSegment = hintSegments[index]
|
|
||||||
if hintSegment != "*", hintSegment != seg {
|
|
||||||
match = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if match { return hint }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSensitivePath(_ path: ConfigPath) -> Bool {
|
|
||||||
let key = pathKey(path).lowercased()
|
|
||||||
return key.contains("token")
|
|
||||||
|| key.contains("password")
|
|
||||||
|| key.contains("secret")
|
|
||||||
|| key.contains("apikey")
|
|
||||||
|| key.hasSuffix("key")
|
|
||||||
}
|
|
||||||
|
|
||||||
func pathKey(_ path: ConfigPath) -> String {
|
|
||||||
path.compactMap { segment -> String? in
|
|
||||||
switch segment {
|
|
||||||
case let .key(key): return key
|
|
||||||
case .index: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.joined(separator: ".")
|
|
||||||
}
|
|
||||||
@@ -4,54 +4,86 @@ import SwiftUI
|
|||||||
struct ConfigSettings: View {
|
struct ConfigSettings: View {
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
private let state = AppStateStore.shared
|
||||||
|
private let labelColumnWidth: CGFloat = 120
|
||||||
|
private static let browserAttachOnlyHelp =
|
||||||
|
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||||
|
private static let browserProfileNote =
|
||||||
|
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||||
|
+ "so it won’t interfere with your daily browser."
|
||||||
|
@State private var configModel: String = ""
|
||||||
|
@State private var configSaving = false
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var models: [ModelChoice] = []
|
||||||
|
@State private var modelsLoading = false
|
||||||
|
@State private var modelSearchQuery: String = ""
|
||||||
|
@State private var isModelPickerOpen = false
|
||||||
|
@State private var modelError: String?
|
||||||
|
@State private var modelsSourceLabel: String?
|
||||||
|
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||||
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
|
@State private var allowAutosave = false
|
||||||
|
@State private var heartbeatMinutes: Int?
|
||||||
|
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
|
||||||
self.store = store
|
@State private var browserEnabled: Bool = true
|
||||||
|
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||||||
|
@State private var browserColorHex: String = "#FF4500"
|
||||||
|
@State private var browserAttachOnly: Bool = false
|
||||||
|
|
||||||
|
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
|
||||||
|
@State private var talkVoiceId: String = ""
|
||||||
|
@State private var talkInterruptOnSpeech: Bool = true
|
||||||
|
@State private var talkApiKey: String = ""
|
||||||
|
@State private var gatewayApiKeyFound = false
|
||||||
|
@FocusState private var modelSearchFocused: Bool
|
||||||
|
|
||||||
|
private struct ConfigDraft {
|
||||||
|
let configModel: String
|
||||||
|
let heartbeatMinutes: Int?
|
||||||
|
let heartbeatBody: String
|
||||||
|
let browserEnabled: Bool
|
||||||
|
let browserControlUrl: String
|
||||||
|
let browserColorHex: String
|
||||||
|
let browserAttachOnly: Bool
|
||||||
|
let talkVoiceId: String
|
||||||
|
let talkApiKey: String
|
||||||
|
let talkInterruptOnSpeech: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView { self.content }
|
||||||
self.content
|
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||||
}
|
Task { await self.loadModels() }
|
||||||
.task {
|
}
|
||||||
guard !self.hasLoaded else { return }
|
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
||||||
guard !self.isPreview else { return }
|
Task { await self.loadModels() }
|
||||||
self.hasLoaded = true
|
}
|
||||||
await self.store.loadConfigSchema()
|
.task {
|
||||||
await self.store.loadConfig()
|
guard !self.hasLoaded else { return }
|
||||||
}
|
guard !self.isPreview else { return }
|
||||||
|
self.hasLoaded = true
|
||||||
|
await self.loadConfig()
|
||||||
|
await self.loadModels()
|
||||||
|
await self.refreshGatewayTalkApiKey()
|
||||||
|
self.allowAutosave = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
if let status = self.store.configStatus {
|
self.agentSection
|
||||||
Text(status)
|
.disabled(self.isNixMode)
|
||||||
.font(.callout)
|
self.heartbeatSection
|
||||||
.foregroundStyle(.secondary)
|
.disabled(self.isNixMode)
|
||||||
}
|
self.talkSection
|
||||||
self.actionRow
|
.disabled(self.isNixMode)
|
||||||
Group {
|
self.browserSection
|
||||||
if self.store.configSchemaLoading {
|
.disabled(self.isNixMode)
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else if let schema = self.store.configSchema {
|
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
|
||||||
} else {
|
|
||||||
Text("Schema unavailable.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.store.configDirty, !self.isNixMode {
|
|
||||||
Text("Unsaved changes")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -62,33 +94,843 @@ extension ConfigSettings {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
Text("Config")
|
Text("Clawdbot CLI config")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text(self.isNixMode
|
Text(self.isNixMode
|
||||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||||
: "Edit ~/.clawdbot/clawdbot.json using the schema-driven form.")
|
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var actionRow: some View {
|
private var agentSection: some View {
|
||||||
HStack(spacing: 10) {
|
GroupBox("Agent") {
|
||||||
Button("Reload") {
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
Task { await self.store.reloadConfigDraft() }
|
GridRow {
|
||||||
|
self.gridLabel("Model")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
self.modelPickerField
|
||||||
|
self.modelMetaLabels
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(!self.store.configLoaded)
|
|
||||||
|
|
||||||
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
|
|
||||||
Task { await self.store.saveConfigDraft() }
|
|
||||||
}
|
|
||||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelPickerField: some View {
|
||||||
|
Button {
|
||||||
|
guard !self.modelsLoading else { return }
|
||||||
|
self.isModelPickerOpen = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(self.modelPickerLabel)
|
||||||
|
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(
|
||||||
|
Color(nsColor: .textBackgroundColor)))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(
|
||||||
|
Color.secondary.opacity(0.25),
|
||||||
|
lineWidth: 1))
|
||||||
|
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
||||||
|
self.modelPickerPopover
|
||||||
|
}
|
||||||
|
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||||
|
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
||||||
|
if isOpen {
|
||||||
|
self.modelSearchQuery = ""
|
||||||
|
self.modelSearchFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelPickerPopover: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
TextField("Search models", text: self.$modelSearchQuery)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.focused(self.$modelSearchFocused)
|
||||||
|
.controlSize(.small)
|
||||||
|
.onSubmit {
|
||||||
|
if let exact = self.exactMatchForQuery() {
|
||||||
|
self.selectModel(exact)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let manual = self.manualEntryCandidate {
|
||||||
|
self.selectManualModel(manual)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.modelSearchMatches.count == 1 {
|
||||||
|
self.selectModel(self.modelSearchMatches[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List {
|
||||||
|
if self.modelSearchMatches.isEmpty {
|
||||||
|
Text("No models match \"\(self.modelSearchQuery)\"")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(self.modelSearchMatches) { choice in
|
||||||
|
Button {
|
||||||
|
self.selectModel(choice)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(choice.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(choice.provider.uppercased())
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.background(Color.secondary.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let manual = self.manualEntryCandidate {
|
||||||
|
Button("Use \"\(manual)\"") {
|
||||||
|
self.selectManualModel(manual)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
.frame(width: 340, height: 260)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var modelMetaLabels: some View {
|
||||||
|
if self.shouldShowProviderHintForSelection {
|
||||||
|
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let contextLabel = self.selectedContextLabel {
|
||||||
|
Text(contextLabel)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authMode = self.selectedAnthropicAuthMode {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||||
|
.help(self.anthropicAuthHelpText)
|
||||||
|
|
||||||
|
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let modelError {
|
||||||
|
Text(modelError)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let modelsSourceLabel {
|
||||||
|
Text("Model catalog: \(modelsSourceLabel)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var anthropicAuthHelpText: String {
|
||||||
|
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
|
||||||
|
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heartbeatSection: some View {
|
||||||
|
GroupBox("Heartbeat") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Schedule")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Stepper(
|
||||||
|
value: Binding(
|
||||||
|
get: { self.heartbeatMinutes ?? 10 },
|
||||||
|
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||||
|
in: 0...720)
|
||||||
|
{
|
||||||
|
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||||
|
.frame(width: 150, alignment: .leading)
|
||||||
|
}
|
||||||
|
.help("Set to 0 to disable automatic heartbeats")
|
||||||
|
|
||||||
|
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.onChange(of: self.heartbeatBody) { _, _ in
|
||||||
|
self.autosaveConfig()
|
||||||
|
}
|
||||||
|
.help("Message body sent on each heartbeat")
|
||||||
|
}
|
||||||
|
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var browserSection: some View {
|
||||||
|
GroupBox("Browser (clawd)") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$browserEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Control URL")
|
||||||
|
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(!self.browserEnabled)
|
||||||
|
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Browser path")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let label = self.browserPathLabel {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
} else {
|
||||||
|
Text("—")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Accent")
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("#FF4500", text: self.$browserColorHex)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 120)
|
||||||
|
.disabled(!self.browserEnabled)
|
||||||
|
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||||
|
Circle()
|
||||||
|
.fill(self.browserColor)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||||
|
Text("lobster-orange")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Attach only")
|
||||||
|
Toggle("", isOn: self.$browserAttachOnly)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.disabled(!self.browserEnabled)
|
||||||
|
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||||
|
.help(Self.browserAttachOnlyHelp)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
|
Text(Self.browserProfileNote)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var talkSection: some View {
|
||||||
|
GroupBox("Talk Mode") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Voice ID")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
||||||
|
if !self.talkVoiceSuggestions.isEmpty {
|
||||||
|
Menu {
|
||||||
|
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
||||||
|
Button(value) {
|
||||||
|
self.talkVoiceId = value
|
||||||
|
self.autosaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
||||||
|
}
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("API key")
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(self.hasEnvApiKey)
|
||||||
|
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||||
|
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
||||||
|
Button("Clear") {
|
||||||
|
self.talkApiKey = ""
|
||||||
|
self.autosaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
||||||
|
if self.hasEnvApiKey {
|
||||||
|
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if self.gatewayApiKeyFound,
|
||||||
|
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
{
|
||||||
|
Text("Using API key from the gateway profile.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Interrupt")
|
||||||
|
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gridLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusLine(label: String, color: Color) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ConfigSettings {
|
||||||
|
private func loadConfig() async {
|
||||||
|
let parsed = await ConfigStore.load()
|
||||||
|
let agents = parsed["agents"] as? [String: Any]
|
||||||
|
let defaults = agents?["defaults"] as? [String: Any]
|
||||||
|
let heartbeat = defaults?["heartbeat"] as? [String: Any]
|
||||||
|
let heartbeatEvery = heartbeat?["every"] as? String
|
||||||
|
let heartbeatBody = heartbeat?["prompt"] as? String
|
||||||
|
let browser = parsed["browser"] as? [String: Any]
|
||||||
|
let talk = parsed["talk"] as? [String: Any]
|
||||||
|
|
||||||
|
let loadedModel: String = {
|
||||||
|
if let raw = defaults?["model"] as? String { return raw }
|
||||||
|
if let modelDict = defaults?["model"] as? [String: Any],
|
||||||
|
let primary = modelDict["primary"] as? String { return primary }
|
||||||
|
return ""
|
||||||
|
}()
|
||||||
|
if !loadedModel.isEmpty {
|
||||||
|
self.configModel = loadedModel
|
||||||
|
} else {
|
||||||
|
self.configModel = SessionLoader.fallbackModel
|
||||||
|
}
|
||||||
|
|
||||||
|
if let heartbeatEvery {
|
||||||
|
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.prefix { $0.isNumber }
|
||||||
|
if let minutes = Int(digits) {
|
||||||
|
self.heartbeatMinutes = minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||||
|
|
||||||
|
if let browser {
|
||||||
|
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
||||||
|
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
||||||
|
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||||
|
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let talk {
|
||||||
|
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
||||||
|
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
||||||
|
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
||||||
|
self.talkInterruptOnSpeech = interrupt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshGatewayTalkApiKey() async {
|
||||||
|
do {
|
||||||
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .configGet,
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: 8000)
|
||||||
|
let talk = snap.config?["talk"]?.dictionaryValue
|
||||||
|
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
||||||
|
} catch {
|
||||||
|
self.gatewayApiKeyFound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autosaveConfig() {
|
||||||
|
guard self.allowAutosave, !self.isNixMode else { return }
|
||||||
|
Task { await self.saveConfig() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveConfig() async {
|
||||||
|
guard !self.configSaving else { return }
|
||||||
|
self.configSaving = true
|
||||||
|
defer { self.configSaving = false }
|
||||||
|
|
||||||
|
let configModel = self.configModel
|
||||||
|
let heartbeatMinutes = self.heartbeatMinutes
|
||||||
|
let heartbeatBody = self.heartbeatBody
|
||||||
|
let browserEnabled = self.browserEnabled
|
||||||
|
let browserControlUrl = self.browserControlUrl
|
||||||
|
let browserColorHex = self.browserColorHex
|
||||||
|
let browserAttachOnly = self.browserAttachOnly
|
||||||
|
let talkVoiceId = self.talkVoiceId
|
||||||
|
let talkApiKey = self.talkApiKey
|
||||||
|
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||||
|
|
||||||
|
let draft = ConfigDraft(
|
||||||
|
configModel: configModel,
|
||||||
|
heartbeatMinutes: heartbeatMinutes,
|
||||||
|
heartbeatBody: heartbeatBody,
|
||||||
|
browserEnabled: browserEnabled,
|
||||||
|
browserControlUrl: browserControlUrl,
|
||||||
|
browserColorHex: browserColorHex,
|
||||||
|
browserAttachOnly: browserAttachOnly,
|
||||||
|
talkVoiceId: talkVoiceId,
|
||||||
|
talkApiKey: talkApiKey,
|
||||||
|
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
||||||
|
|
||||||
|
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
self.modelError = errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||||
|
var root = await ConfigStore.load()
|
||||||
|
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||||
|
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||||
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||||
|
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedModel = chosenModel
|
||||||
|
if !trimmedModel.isEmpty {
|
||||||
|
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||||
|
model["primary"] = trimmedModel
|
||||||
|
defaults["model"] = model
|
||||||
|
|
||||||
|
var models = defaults["models"] as? [String: Any] ?? [:]
|
||||||
|
if models[trimmedModel] == nil {
|
||||||
|
models[trimmedModel] = [:]
|
||||||
|
}
|
||||||
|
defaults["models"] = models
|
||||||
|
}
|
||||||
|
|
||||||
|
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||||
|
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||||
|
heartbeat["every"] = "\(heartbeatMinutes)m"
|
||||||
|
defaults["heartbeat"] = heartbeat
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedBody.isEmpty {
|
||||||
|
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||||
|
heartbeat["prompt"] = trimmedBody
|
||||||
|
defaults["heartbeat"] = heartbeat
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaults.isEmpty {
|
||||||
|
agents.removeValue(forKey: "defaults")
|
||||||
|
} else {
|
||||||
|
agents["defaults"] = defaults
|
||||||
|
}
|
||||||
|
if agents.isEmpty {
|
||||||
|
root.removeValue(forKey: "agents")
|
||||||
|
} else {
|
||||||
|
root["agents"] = agents
|
||||||
|
}
|
||||||
|
|
||||||
|
browser["enabled"] = draft.browserEnabled
|
||||||
|
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||||
|
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||||
|
browser["attachOnly"] = draft.browserAttachOnly
|
||||||
|
root["browser"] = browser
|
||||||
|
|
||||||
|
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedVoice.isEmpty {
|
||||||
|
talk.removeValue(forKey: "voiceId")
|
||||||
|
} else {
|
||||||
|
talk["voiceId"] = trimmedVoice
|
||||||
|
}
|
||||||
|
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedApiKey.isEmpty {
|
||||||
|
talk.removeValue(forKey: "apiKey")
|
||||||
|
} else {
|
||||||
|
talk["apiKey"] = trimmedApiKey
|
||||||
|
}
|
||||||
|
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
||||||
|
root["talk"] = talk
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await ConfigStore.save(root)
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConfigSettings {
|
||||||
|
private var browserColor: Color {
|
||||||
|
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||||
|
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
||||||
|
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||||
|
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||||
|
let b = Double(value & 0xFF) / 255.0
|
||||||
|
return Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var talkVoiceSuggestions: [String] {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
let candidates = [
|
||||||
|
self.talkVoiceId,
|
||||||
|
env["ELEVENLABS_VOICE_ID"] ?? "",
|
||||||
|
env["SAG_VOICE_ID"] ?? "",
|
||||||
|
]
|
||||||
|
var seen = Set<String>()
|
||||||
|
return candidates
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasEnvApiKey: Bool {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
||||||
|
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apiKeyStatusLabel: String {
|
||||||
|
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
||||||
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return "ElevenLabs API key: stored in config"
|
||||||
|
}
|
||||||
|
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
||||||
|
return "ElevenLabs API key: missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var apiKeyStatusColor: Color {
|
||||||
|
if self.hasEnvApiKey { return .green }
|
||||||
|
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
||||||
|
if self.gatewayApiKeyFound { return .green }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var browserPathLabel: String? {
|
||||||
|
guard self.browserEnabled else { return nil }
|
||||||
|
|
||||||
|
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
||||||
|
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
||||||
|
return "remote (\(host))"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
||||||
|
return candidate.executablePath ?? candidate.appPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BrowserCandidate {
|
||||||
|
let name: String
|
||||||
|
let appPath: String
|
||||||
|
let executablePath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
||||||
|
let candidates: [(name: String, appName: String)] = [
|
||||||
|
("Google Chrome Canary", "Google Chrome Canary.app"),
|
||||||
|
("Chromium", "Chromium.app"),
|
||||||
|
("Google Chrome", "Google Chrome.app"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let roots = [
|
||||||
|
"/Applications",
|
||||||
|
"\(NSHomeDirectory())/Applications",
|
||||||
|
]
|
||||||
|
|
||||||
|
let fm = FileManager.default
|
||||||
|
for (name, appName) in candidates {
|
||||||
|
for root in roots {
|
||||||
|
let appPath = "\(root)/\(appName)"
|
||||||
|
if fm.fileExists(atPath: appPath) {
|
||||||
|
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
||||||
|
let exec = bundle?.executableURL?.path
|
||||||
|
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||||
|
if host == "localhost" { return true }
|
||||||
|
if host == "127.0.0.1" { return true }
|
||||||
|
if host == "::1" { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConfigSettings {
|
||||||
|
private func loadModels() async {
|
||||||
|
guard !self.modelsLoading else { return }
|
||||||
|
self.modelsLoading = true
|
||||||
|
self.modelError = nil
|
||||||
|
self.modelsSourceLabel = nil
|
||||||
|
do {
|
||||||
|
let res: ModelsListResult =
|
||||||
|
try await GatewayConnection.shared
|
||||||
|
.requestDecoded(
|
||||||
|
method: .modelsList,
|
||||||
|
timeoutMs: 15000)
|
||||||
|
self.models = res.models
|
||||||
|
self.modelsSourceLabel = "gateway"
|
||||||
|
} catch {
|
||||||
|
do {
|
||||||
|
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||||
|
self.models = loaded
|
||||||
|
self.modelsSourceLabel = "local fallback"
|
||||||
|
} catch {
|
||||||
|
self.modelError = error.localizedDescription
|
||||||
|
self.models = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.modelsLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ModelsListResult: Decodable {
|
||||||
|
let models: [ModelChoice]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelSearchMatches: [ModelChoice] {
|
||||||
|
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard !raw.isEmpty else { return self.models }
|
||||||
|
let tokens = raw
|
||||||
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
|
.map { token in
|
||||||
|
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||||
|
}
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard !tokens.isEmpty else { return self.models }
|
||||||
|
return self.models.filter { choice in
|
||||||
|
let haystack = [
|
||||||
|
choice.id,
|
||||||
|
choice.name,
|
||||||
|
choice.provider,
|
||||||
|
self.modelRef(for: choice),
|
||||||
|
]
|
||||||
|
.joined(separator: " ")
|
||||||
|
.lowercased()
|
||||||
|
return tokens.allSatisfy { haystack.contains($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedModelChoice: ModelChoice? {
|
||||||
|
guard !self.configModel.isEmpty else { return nil }
|
||||||
|
return self.models.first(where: { self.matchesConfigModel($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelPickerLabel: String {
|
||||||
|
if let choice = self.selectedModelChoice {
|
||||||
|
return "\(choice.name) — \(choice.provider.uppercased())"
|
||||||
|
}
|
||||||
|
if !self.configModel.isEmpty { return self.configModel }
|
||||||
|
return "Select model"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelPickerLabelIsPlaceholder: Bool {
|
||||||
|
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualEntryCandidate: String? {
|
||||||
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||||
|
guard !cleaned.isEmpty else { return nil }
|
||||||
|
guard !self.isKnownModelRef(cleaned) else { return nil }
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isKnownModelRef(_ value: String) -> Bool {
|
||||||
|
let needle = value.lowercased()
|
||||||
|
return self.models.contains { choice in
|
||||||
|
choice.id.lowercased() == needle
|
||||||
|
|| self.modelRef(for: choice).lowercased() == needle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modelRef(for choice: ModelChoice) -> String {
|
||||||
|
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !provider.isEmpty else { return id }
|
||||||
|
let normalizedProvider = provider.lowercased()
|
||||||
|
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return "\(normalizedProvider)/\(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
||||||
|
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !configured.isEmpty else { return false }
|
||||||
|
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
||||||
|
let ref = self.modelRef(for: choice)
|
||||||
|
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exactMatchForQuery() -> ModelChoice? {
|
||||||
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
||||||
|
guard !cleaned.isEmpty else { return nil }
|
||||||
|
return self.models.first(where: { choice in
|
||||||
|
let id = choice.id.lowercased()
|
||||||
|
if id == cleaned { return true }
|
||||||
|
return self.modelRef(for: choice).lowercased() == cleaned
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowProviderHint: Bool {
|
||||||
|
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||||
|
return !cleaned.contains("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowProviderHintForSelection: Bool {
|
||||||
|
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
return !trimmed.contains("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectModel(_ choice: ModelChoice) {
|
||||||
|
self.configModel = self.modelRef(for: choice)
|
||||||
|
self.autosaveConfig()
|
||||||
|
self.isModelPickerOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectManualModel(_ value: String) {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let slash = trimmed.firstIndex(of: "/") {
|
||||||
|
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
||||||
|
} else {
|
||||||
|
self.configModel = trimmed
|
||||||
|
}
|
||||||
|
self.autosaveConfig()
|
||||||
|
self.isModelPickerOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedContextLabel: String? {
|
||||||
|
guard
|
||||||
|
let choice = self.selectedModelChoice,
|
||||||
|
let context = choice.contextWindow
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||||
|
return "Context window: \(human) tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||||
|
guard let choice = self.selectedModelChoice else { return nil }
|
||||||
|
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||||
|
return AnthropicAuthResolver.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
configuration.label
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
configuration.content
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
struct ConfigSettings_Previews: PreviewProvider {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ConfigSettings()
|
ConfigSettings()
|
||||||
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
|
||||||
case configMode
|
|
||||||
case configRemoteURL
|
|
||||||
case userDefaults
|
|
||||||
case onboarding
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EffectiveConnectionMode: Sendable, Equatable {
|
|
||||||
let mode: AppState.ConnectionMode
|
|
||||||
let source: EffectiveConnectionModeSource
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConnectionModeResolver {
|
|
||||||
static func resolve(
|
|
||||||
root: [String: Any],
|
|
||||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
|
||||||
{
|
|
||||||
let gateway = root["gateway"] as? [String: Any]
|
|
||||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
|
||||||
let configMode = configModeRaw
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
|
|
||||||
switch configMode {
|
|
||||||
case "local":
|
|
||||||
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
|
||||||
case "remote":
|
|
||||||
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
|
||||||
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !remoteURL.isEmpty {
|
|
||||||
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
|
||||||
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
|
||||||
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
|
||||||
}
|
|
||||||
|
|
||||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
|
||||||
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension ConnectionsSettings {
|
||||||
|
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||||
|
GroupBox(title) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func channelHeaderActions(_ channel: ConnectionChannel) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if channel == .whatsapp {
|
||||||
|
Button("Logout") {
|
||||||
|
Task { await self.store.logoutWhatsApp() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.whatsappBusy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel == .telegram {
|
||||||
|
Button("Logout") {
|
||||||
|
Task { await self.store.logoutTelegram() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.telegramBusy)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await self.store.refresh(probe: true) }
|
||||||
|
} label: {
|
||||||
|
if self.store.isRefreshing {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.isRefreshing)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
var whatsAppSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.formSection("Linking") {
|
||||||
|
if let message = self.store.whatsappLoginMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.interpolation(.none)
|
||||||
|
.frame(width: 180, height: 180)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.startWhatsAppLogin(force: false) }
|
||||||
|
} label: {
|
||||||
|
if self.store.whatsappBusy {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Show QR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.whatsappBusy)
|
||||||
|
|
||||||
|
Button("Relink") {
|
||||||
|
Task { await self.store.startWhatsAppLogin(force: true) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.store.whatsappBusy)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var telegramSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.formSection("Authentication") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Bot token")
|
||||||
|
if self.showTelegramToken {
|
||||||
|
TextField("123:abc", text: self.$store.telegramToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isTelegramTokenLocked)
|
||||||
|
} else {
|
||||||
|
SecureField("123:abc", text: self.$store.telegramToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isTelegramTokenLocked)
|
||||||
|
}
|
||||||
|
Toggle("Show", isOn: self.$showTelegramToken)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.disabled(self.isTelegramTokenLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Access") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Require mention")
|
||||||
|
Toggle("", isOn: self.$store.telegramRequireMention)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow from")
|
||||||
|
TextField("123456789, @team", text: self.$store.telegramAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Webhook") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Webhook URL")
|
||||||
|
TextField("https://example.com/telegram-webhook", text: self.$store.telegramWebhookUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Webhook secret")
|
||||||
|
TextField("secret", text: self.$store.telegramWebhookSecret)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Webhook path")
|
||||||
|
TextField("/telegram-webhook", text: self.$store.telegramWebhookPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Network") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Proxy")
|
||||||
|
TextField("socks5://localhost:9050", text: self.$store.telegramProxy)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isTelegramTokenLocked {
|
||||||
|
Text("Token set via TELEGRAM_BOT_TOKEN env; config edits won’t override it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.configStatusMessage
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.saveTelegramConfig() }
|
||||||
|
} label: {
|
||||||
|
if self.store.isSavingConfig {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.isSavingConfig)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var discordSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.formSection("Authentication") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$store.discordEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Bot token")
|
||||||
|
if self.showDiscordToken {
|
||||||
|
TextField("bot token", text: self.$store.discordToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
} else {
|
||||||
|
SecureField("bot token", text: self.$store.discordToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
}
|
||||||
|
Toggle("Show", isOn: self.$showDiscordToken)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.disabled(self.isDiscordTokenLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Messages") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow DMs from")
|
||||||
|
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("DMs enabled")
|
||||||
|
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Group DMs")
|
||||||
|
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Group channels")
|
||||||
|
TextField("channelId1, channelId2", text: self.$store.discordGroupChannels)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Reply to mode")
|
||||||
|
Picker("", selection: self.$store.discordReplyToMode) {
|
||||||
|
Text("off").tag("off")
|
||||||
|
Text("first").tag("first")
|
||||||
|
Text("all").tag("all")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Limits") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Media max MB")
|
||||||
|
TextField("8", text: self.$store.discordMediaMaxMb)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("History limit")
|
||||||
|
TextField("20", text: self.$store.discordHistoryLimit)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Text chunk limit")
|
||||||
|
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Slash command") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Slash name")
|
||||||
|
TextField("clawd", text: self.$store.discordSlashName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Session prefix")
|
||||||
|
TextField("discord:slash", text: self.$store.discordSlashSessionPrefix)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Ephemeral")
|
||||||
|
Toggle("", isOn: self.$store.discordSlashEphemeral)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox("Guilds") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ForEach(self.$store.discordGuilds) { $guild in
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
TextField("guild id or slug", text: $guild.key)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button("Remove") {
|
||||||
|
self.store.discordGuilds.removeAll { $0.id == guild.id }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Slug")
|
||||||
|
TextField("optional slug", text: $guild.slug)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Require mention")
|
||||||
|
Toggle("", isOn: $guild.requireMention)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Reaction notifications")
|
||||||
|
Picker("", selection: $guild.reactionNotifications) {
|
||||||
|
Text("Off").tag("off")
|
||||||
|
Text("Own").tag("own")
|
||||||
|
Text("All").tag("all")
|
||||||
|
Text("Allowlist").tag("allowlist")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Users allowlist")
|
||||||
|
TextField("123456789, username#1234", text: $guild.users)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Channels")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach($guild.channels) { $channel in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
TextField("channel id or slug", text: $channel.key)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Toggle("Allow", isOn: $channel.allow)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
Toggle("Require mention", isOn: $channel.requireMention)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
Button("Remove") {
|
||||||
|
guild.channels.removeAll { $0.id == channel.id }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Add channel") {
|
||||||
|
guild.channels.append(DiscordGuildChannelForm())
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Add guild") {
|
||||||
|
self.store.discordGuilds.append(DiscordGuildForm())
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox("Tool actions") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Reactions")
|
||||||
|
Toggle("", isOn: self.$store.discordActionReactions)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Stickers")
|
||||||
|
Toggle("", isOn: self.$store.discordActionStickers)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Polls")
|
||||||
|
Toggle("", isOn: self.$store.discordActionPolls)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Permissions")
|
||||||
|
Toggle("", isOn: self.$store.discordActionPermissions)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Messages")
|
||||||
|
Toggle("", isOn: self.$store.discordActionMessages)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Threads")
|
||||||
|
Toggle("", isOn: self.$store.discordActionThreads)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Pins")
|
||||||
|
Toggle("", isOn: self.$store.discordActionPins)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Search")
|
||||||
|
Toggle("", isOn: self.$store.discordActionSearch)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Member info")
|
||||||
|
Toggle("", isOn: self.$store.discordActionMemberInfo)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Role info")
|
||||||
|
Toggle("", isOn: self.$store.discordActionRoleInfo)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Channel info")
|
||||||
|
Toggle("", isOn: self.$store.discordActionChannelInfo)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Voice status")
|
||||||
|
Toggle("", isOn: self.$store.discordActionVoiceStatus)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Events")
|
||||||
|
Toggle("", isOn: self.$store.discordActionEvents)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Role changes")
|
||||||
|
Toggle("", isOn: self.$store.discordActionRoles)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Moderation")
|
||||||
|
Toggle("", isOn: self.$store.discordActionModeration)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isDiscordTokenLocked {
|
||||||
|
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.configStatusMessage
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.saveDiscordConfig() }
|
||||||
|
} label: {
|
||||||
|
if self.store.isSavingConfig {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.isSavingConfig)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var signalSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.formSection("Connection") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$store.signalEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Account")
|
||||||
|
TextField("+15551234567", text: self.$store.signalAccount)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("HTTP URL")
|
||||||
|
TextField("http://127.0.0.1:8080", text: self.$store.signalHttpUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("HTTP host")
|
||||||
|
TextField("127.0.0.1", text: self.$store.signalHttpHost)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("HTTP port")
|
||||||
|
TextField("8080", text: self.$store.signalHttpPort)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("CLI path")
|
||||||
|
TextField("signal-cli", text: self.$store.signalCliPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Behavior") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Auto start")
|
||||||
|
Toggle("", isOn: self.$store.signalAutoStart)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Receive mode")
|
||||||
|
Picker("", selection: self.$store.signalReceiveMode) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
Text("on-start").tag("on-start")
|
||||||
|
Text("manual").tag("manual")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Ignore attachments")
|
||||||
|
Toggle("", isOn: self.$store.signalIgnoreAttachments)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Ignore stories")
|
||||||
|
Toggle("", isOn: self.$store.signalIgnoreStories)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Read receipts")
|
||||||
|
Toggle("", isOn: self.$store.signalSendReadReceipts)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Access & limits") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow from")
|
||||||
|
TextField("12345, +1555", text: self.$store.signalAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Media max MB")
|
||||||
|
TextField("8", text: self.$store.signalMediaMaxMb)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.configStatusMessage
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.saveSignalConfig() }
|
||||||
|
} label: {
|
||||||
|
if self.store.isSavingConfig {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.isSavingConfig)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var imessageSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
self.formSection("Connection") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Enabled")
|
||||||
|
Toggle("", isOn: self.$store.imessageEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("CLI path")
|
||||||
|
TextField("imsg", text: self.$store.imessageCliPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("DB path")
|
||||||
|
TextField("~/Library/Messages/chat.db", text: self.$store.imessageDbPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Service")
|
||||||
|
Picker("", selection: self.$store.imessageService) {
|
||||||
|
Text("auto").tag("auto")
|
||||||
|
Text("imessage").tag("imessage")
|
||||||
|
Text("sms").tag("sms")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formSection("Behavior") {
|
||||||
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Region")
|
||||||
|
TextField("US", text: self.$store.imessageRegion)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Allow from")
|
||||||
|
TextField("chat_id:101, +1555", text: self.$store.imessageAllowFrom)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Attachments")
|
||||||
|
Toggle("", isOn: self.$store.imessageIncludeAttachments)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("Media max MB")
|
||||||
|
TextField("16", text: self.$store.imessageMediaMaxMb)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.configStatusMessage
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await self.store.saveIMessageConfig() }
|
||||||
|
} label: {
|
||||||
|
if self.store.isSavingConfig {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.store.isSavingConfig)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var configStatusMessage: some View {
|
||||||
|
if let status = self.store.configStatus {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gridLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-141
@@ -1,7 +1,6 @@
|
|||||||
import ClawdbotProtocol
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension ChannelsSettings {
|
extension ConnectionsSettings {
|
||||||
private func channelStatus<T: Decodable>(
|
private func channelStatus<T: Decodable>(
|
||||||
_ id: String,
|
_ id: String,
|
||||||
as type: T.Type) -> T?
|
as type: T.Type) -> T?
|
||||||
@@ -243,18 +242,16 @@ extension ChannelsSettings {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var isTelegramTokenLocked: Bool {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
}
|
||||||
let channels = order.enumerated().map { index, id in
|
|
||||||
ChannelItem(
|
var isDiscordTokenLocked: Bool {
|
||||||
id: id,
|
self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||||
title: self.resolveChannelTitle(id),
|
}
|
||||||
detailTitle: self.resolveChannelDetailTitle(id),
|
|
||||||
systemImage: self.resolveChannelSystemImage(id),
|
var orderedChannels: [ConnectionChannel] {
|
||||||
sortOrder: index)
|
ConnectionChannel.allCases.sorted { lhs, rhs in
|
||||||
}
|
|
||||||
return channels.sorted { lhs, rhs in
|
|
||||||
let lhsEnabled = self.channelEnabled(lhs)
|
let lhsEnabled = self.channelEnabled(lhs)
|
||||||
let rhsEnabled = self.channelEnabled(rhs)
|
let rhsEnabled = self.channelEnabled(rhs)
|
||||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||||
@@ -262,11 +259,11 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabledChannels: [ChannelItem] {
|
var enabledChannels: [ConnectionChannel] {
|
||||||
self.orderedChannels.filter { self.channelEnabled($0) }
|
self.orderedChannels.filter { self.channelEnabled($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableChannels: [ChannelItem] {
|
var availableChannels: [ConnectionChannel] {
|
||||||
self.orderedChannels.filter { !self.channelEnabled($0) }
|
self.orderedChannels.filter { !self.channelEnabled($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,183 +277,143 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelEnabled(_ channel: ChannelItem) -> Bool {
|
func channelEnabled(_ channel: ConnectionChannel) -> Bool {
|
||||||
let status = self.channelStatusDictionary(channel.id)
|
switch channel {
|
||||||
let configured = status?["configured"]?.boolValue ?? false
|
case .whatsapp:
|
||||||
let running = status?["running"]?.boolValue ?? false
|
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||||
let connected = status?["connected"]?.boolValue ?? false
|
else { return false }
|
||||||
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
|
return status.configured || status.linked || status.running
|
||||||
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
|
case .telegram:
|
||||||
return configured || running || connected || accountActive
|
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.configured || status.running
|
||||||
|
case .discord:
|
||||||
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.configured || status.running
|
||||||
|
case .signal:
|
||||||
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.configured || status.running
|
||||||
|
case .imessage:
|
||||||
|
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.configured || status.running
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func channelSection(_ channel: ChannelItem) -> some View {
|
func channelSection(_ channel: ConnectionChannel) -> some View {
|
||||||
if channel.id == "whatsapp" {
|
switch channel {
|
||||||
|
case .whatsapp:
|
||||||
self.whatsAppSection
|
self.whatsAppSection
|
||||||
} else {
|
case .telegram:
|
||||||
self.genericChannelSection(channel)
|
self.telegramSection
|
||||||
|
case .discord:
|
||||||
|
self.discordSection
|
||||||
|
case .signal:
|
||||||
|
self.signalSection
|
||||||
|
case .imessage:
|
||||||
|
self.imessageSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelTint(_ channel: ChannelItem) -> Color {
|
func channelTint(_ channel: ConnectionChannel) -> Color {
|
||||||
switch channel.id {
|
switch channel {
|
||||||
case "whatsapp":
|
case .whatsapp:
|
||||||
return self.whatsAppTint
|
self.whatsAppTint
|
||||||
case "telegram":
|
case .telegram:
|
||||||
return self.telegramTint
|
self.telegramTint
|
||||||
case "discord":
|
case .discord:
|
||||||
return self.discordTint
|
self.discordTint
|
||||||
case "signal":
|
case .signal:
|
||||||
return self.signalTint
|
self.signalTint
|
||||||
case "imessage":
|
case .imessage:
|
||||||
return self.imessageTint
|
self.imessageTint
|
||||||
default:
|
|
||||||
if self.channelHasError(channel) { return .orange }
|
|
||||||
if self.channelEnabled(channel) { return .green }
|
|
||||||
return .secondary
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelSummary(_ channel: ChannelItem) -> String {
|
func channelSummary(_ channel: ConnectionChannel) -> String {
|
||||||
switch channel.id {
|
switch channel {
|
||||||
case "whatsapp":
|
case .whatsapp:
|
||||||
return self.whatsAppSummary
|
self.whatsAppSummary
|
||||||
case "telegram":
|
case .telegram:
|
||||||
return self.telegramSummary
|
self.telegramSummary
|
||||||
case "discord":
|
case .discord:
|
||||||
return self.discordSummary
|
self.discordSummary
|
||||||
case "signal":
|
case .signal:
|
||||||
return self.signalSummary
|
self.signalSummary
|
||||||
case "imessage":
|
case .imessage:
|
||||||
return self.imessageSummary
|
self.imessageSummary
|
||||||
default:
|
|
||||||
if self.channelHasError(channel) { return "Error" }
|
|
||||||
if self.channelEnabled(channel) { return "Active" }
|
|
||||||
return "Not configured"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelDetails(_ channel: ChannelItem) -> String? {
|
func channelDetails(_ channel: ConnectionChannel) -> String? {
|
||||||
switch channel.id {
|
switch channel {
|
||||||
case "whatsapp":
|
case .whatsapp:
|
||||||
return self.whatsAppDetails
|
self.whatsAppDetails
|
||||||
case "telegram":
|
case .telegram:
|
||||||
return self.telegramDetails
|
self.telegramDetails
|
||||||
case "discord":
|
case .discord:
|
||||||
return self.discordDetails
|
self.discordDetails
|
||||||
case "signal":
|
case .signal:
|
||||||
return self.signalDetails
|
self.signalDetails
|
||||||
case "imessage":
|
case .imessage:
|
||||||
return self.imessageDetails
|
self.imessageDetails
|
||||||
default:
|
|
||||||
let status = self.channelStatusDictionary(channel.id)
|
|
||||||
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
|
|
||||||
return "Error: \(err)"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelLastCheckText(_ channel: ChannelItem) -> String {
|
func channelLastCheckText(_ channel: ConnectionChannel) -> String {
|
||||||
guard let date = self.channelLastCheck(channel) else { return "never" }
|
guard let date = self.channelLastCheck(channel) else { return "never" }
|
||||||
return relativeAge(from: date)
|
return relativeAge(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelLastCheck(_ channel: ChannelItem) -> Date? {
|
func channelLastCheck(_ channel: ConnectionChannel) -> Date? {
|
||||||
switch channel.id {
|
switch channel {
|
||||||
case "whatsapp":
|
case .whatsapp:
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||||
case "telegram":
|
case .telegram:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case "discord":
|
case .discord:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case "signal":
|
case .signal:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
case "imessage":
|
case .imessage:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
default:
|
|
||||||
let status = self.channelStatusDictionary(channel.id)
|
|
||||||
if let probeAt = status?["lastProbeAt"]?.doubleValue {
|
|
||||||
return self.date(fromMs: probeAt)
|
|
||||||
}
|
|
||||||
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
|
|
||||||
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
|
|
||||||
return self.date(fromMs: last)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelHasError(_ channel: ChannelItem) -> Bool {
|
func channelHasError(_ channel: ConnectionChannel) -> Bool {
|
||||||
switch channel.id {
|
switch channel {
|
||||||
case "whatsapp":
|
case .whatsapp:
|
||||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||||
case "telegram":
|
case .telegram:
|
||||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "discord":
|
case .discord:
|
||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "signal":
|
case .signal:
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "imessage":
|
case .imessage:
|
||||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
default:
|
|
||||||
let status = self.channelStatusDictionary(channel.id)
|
|
||||||
return status?["lastError"]?.stringValue?.isEmpty == false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelTitle(_ id: String) -> String {
|
|
||||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return id.prefix(1).uppercased() + id.dropFirst()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
|
||||||
switch id {
|
|
||||||
case "whatsapp": "WhatsApp Web"
|
|
||||||
case "telegram": "Telegram Bot"
|
|
||||||
case "discord": "Discord Bot"
|
|
||||||
case "slack": "Slack Bot"
|
|
||||||
case "signal": "Signal REST"
|
|
||||||
case "imessage": "iMessage"
|
|
||||||
default: self.resolveChannelTitle(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
|
||||||
switch id {
|
|
||||||
case "whatsapp": "message"
|
|
||||||
case "telegram": "paperplane"
|
|
||||||
case "discord": "bubble.left.and.bubble.right"
|
|
||||||
case "slack": "number"
|
|
||||||
case "signal": "antenna.radiowaves.left.and.right"
|
|
||||||
case "imessage": "message.fill"
|
|
||||||
default: "message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
|
||||||
self.store.snapshot?.channels[id]?.dictionaryValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
extension ChannelsSettings {
|
extension ConnectionsSettings {
|
||||||
func date(fromMs ms: Double?) -> Date? {
|
func date(fromMs ms: Double?) -> Date? {
|
||||||
guard let ms else { return nil }
|
guard let ms else { return nil }
|
||||||
return Date(timeIntervalSince1970: ms / 1000)
|
return Date(timeIntervalSince1970: ms / 1000)
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension ChannelsSettings {
|
extension ConnectionsSettings {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
self.sidebar
|
self.sidebar
|
||||||
@@ -57,7 +57,7 @@ extension ChannelsSettings {
|
|||||||
|
|
||||||
private var emptyDetail: some View {
|
private var emptyDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Channels")
|
Text("Connections")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("Select a channel to view status and settings.")
|
Text("Select a channel to view status and settings.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -67,7 +67,7 @@ extension ChannelsSettings {
|
|||||||
.padding(.vertical, 18)
|
.padding(.vertical, 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelDetail(_ channel: ChannelItem) -> some View {
|
private func channelDetail(_ channel: ConnectionChannel) -> some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
self.detailHeader(for: channel)
|
self.detailHeader(for: channel)
|
||||||
@@ -81,7 +81,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarRow(_ channel: ChannelItem) -> some View {
|
private func sidebarRow(_ channel: ConnectionChannel) -> some View {
|
||||||
let isSelected = self.selectedChannel == channel
|
let isSelected = self.selectedChannel == channel
|
||||||
return Button {
|
return Button {
|
||||||
self.selectedChannel = channel
|
self.selectedChannel = channel
|
||||||
@@ -119,7 +119,7 @@ extension ChannelsSettings {
|
|||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func detailHeader(for channel: ChannelItem) -> some View {
|
private func detailHeader(for channel: ConnectionChannel) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
Label(channel.detailTitle, systemImage: channel.systemImage)
|
Label(channel.detailTitle, systemImage: channel.systemImage)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ConnectionsSettings: View {
|
||||||
|
enum ConnectionChannel: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case whatsapp
|
||||||
|
case telegram
|
||||||
|
case discord
|
||||||
|
case signal
|
||||||
|
case imessage
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var sortOrder: Int {
|
||||||
|
switch self {
|
||||||
|
case .whatsapp: 0
|
||||||
|
case .telegram: 1
|
||||||
|
case .discord: 2
|
||||||
|
case .signal: 3
|
||||||
|
case .imessage: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .whatsapp: "WhatsApp"
|
||||||
|
case .telegram: "Telegram"
|
||||||
|
case .discord: "Discord"
|
||||||
|
case .signal: "Signal"
|
||||||
|
case .imessage: "iMessage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .whatsapp: "WhatsApp Web"
|
||||||
|
case .telegram: "Telegram Bot"
|
||||||
|
case .discord: "Discord Bot"
|
||||||
|
case .signal: "Signal REST"
|
||||||
|
case .imessage: "iMessage (imsg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .whatsapp: "message"
|
||||||
|
case .telegram: "paperplane"
|
||||||
|
case .discord: "bubble.left.and.bubble.right"
|
||||||
|
case .signal: "antenna.radiowaves.left.and.right"
|
||||||
|
case .imessage: "message.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bindable var store: ConnectionsStore
|
||||||
|
@State var selectedChannel: ConnectionChannel?
|
||||||
|
@State var showTelegramToken = false
|
||||||
|
@State var showDiscordToken = false
|
||||||
|
|
||||||
|
init(store: ConnectionsStore = .shared) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
import ClawdbotProtocol
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension ConnectionsStore {
|
||||||
|
var isTelegramTokenLocked: Bool {
|
||||||
|
self.snapshot?.decodeChannel("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||||
|
.tokenSource == "env"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDiscordTokenLocked: Bool {
|
||||||
|
self.snapshot?.decodeChannel("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
|
.tokenSource == "env"
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() async {
|
||||||
|
do {
|
||||||
|
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||||
|
method: .configGet,
|
||||||
|
params: nil,
|
||||||
|
timeoutMs: 10000)
|
||||||
|
self.configStatus = snap.valid == false
|
||||||
|
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||||
|
: nil
|
||||||
|
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||||
|
self.configHash = snap.hash
|
||||||
|
self.configLoaded = true
|
||||||
|
|
||||||
|
self.applyUIConfig(snap)
|
||||||
|
self.applyTelegramConfig(snap)
|
||||||
|
self.applyDiscordConfig(snap)
|
||||||
|
self.applySignalConfig(snap)
|
||||||
|
self.applyIMessageConfig(snap)
|
||||||
|
} catch {
|
||||||
|
self.configStatus = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||||
|
let ui = snap.config?[
|
||||||
|
"ui",
|
||||||
|
]?.dictionaryValue
|
||||||
|
let rawSeam = ui?[
|
||||||
|
"seamColor",
|
||||||
|
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||||
|
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||||
|
let entry = channels[key]?.dictionaryValue
|
||||||
|
{
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
return snap.config?[key]?.dictionaryValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||||
|
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||||
|
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||||
|
let groups = telegram?["groups"]?.dictionaryValue
|
||||||
|
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||||
|
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||||
|
?? telegram?["requireMention"]?.boolValue
|
||||||
|
?? true
|
||||||
|
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||||
|
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||||
|
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||||
|
self.telegramWebhookSecret = telegram?["webhookSecret"]?.stringValue ?? ""
|
||||||
|
self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||||
|
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||||
|
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||||
|
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||||
|
|
||||||
|
let discordDm = discord?["dm"]?.dictionaryValue
|
||||||
|
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||||
|
self.discordAllowFrom = self.stringList(from: discordDm?["allowFrom"]?.arrayValue)
|
||||||
|
self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false
|
||||||
|
self.discordGroupChannels = self.stringList(from: discordDm?["groupChannels"]?.arrayValue)
|
||||||
|
self.discordMediaMaxMb = self.numberString(from: discord?["mediaMaxMb"])
|
||||||
|
self.discordHistoryLimit = self.numberString(from: discord?["historyLimit"])
|
||||||
|
self.discordTextChunkLimit = self.numberString(from: discord?["textChunkLimit"])
|
||||||
|
self.discordReplyToMode = self.replyMode(from: discord?["replyToMode"]?.stringValue)
|
||||||
|
self.discordGuilds = self.decodeDiscordGuilds(discord?["guilds"]?.dictionaryValue)
|
||||||
|
|
||||||
|
let discordActions = discord?["actions"]?.dictionaryValue
|
||||||
|
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||||
|
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||||
|
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
|
||||||
|
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
|
||||||
|
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
|
||||||
|
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
|
||||||
|
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
|
||||||
|
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
|
||||||
|
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
|
||||||
|
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
|
||||||
|
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
|
||||||
|
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
|
||||||
|
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
|
||||||
|
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
|
||||||
|
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
|
||||||
|
|
||||||
|
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||||
|
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||||
|
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||||
|
self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? ""
|
||||||
|
self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeDiscordGuilds(_ guilds: [String: AnyCodable]?) -> [DiscordGuildForm] {
|
||||||
|
guard let guilds else { return [] }
|
||||||
|
return guilds
|
||||||
|
.map { key, value in
|
||||||
|
let entry = value.dictionaryValue ?? [:]
|
||||||
|
let slug = entry["slug"]?.stringValue ?? ""
|
||||||
|
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||||
|
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
|
||||||
|
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
|
||||||
|
? reactionModeRaw
|
||||||
|
: "own"
|
||||||
|
let users = self.stringList(from: entry["users"]?.arrayValue)
|
||||||
|
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?.dictionaryValue {
|
||||||
|
channelMap.map { channelKey, channelValue in
|
||||||
|
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||||
|
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||||
|
let channelRequireMention = channelEntry["requireMention"]?.boolValue ?? false
|
||||||
|
return DiscordGuildChannelForm(
|
||||||
|
key: channelKey,
|
||||||
|
allow: allow,
|
||||||
|
requireMention: channelRequireMention)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
return DiscordGuildForm(
|
||||||
|
key: key,
|
||||||
|
slug: slug,
|
||||||
|
requireMention: requireMention,
|
||||||
|
reactionNotifications: reactionNotifications,
|
||||||
|
users: users,
|
||||||
|
channels: channels)
|
||||||
|
}
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||||
|
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||||
|
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||||
|
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||||
|
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||||
|
self.signalHttpHost = signal?["httpHost"]?.stringValue ?? ""
|
||||||
|
self.signalHttpPort = self.numberString(from: signal?["httpPort"])
|
||||||
|
self.signalCliPath = signal?["cliPath"]?.stringValue ?? ""
|
||||||
|
self.signalAutoStart = signal?["autoStart"]?.boolValue ?? true
|
||||||
|
self.signalReceiveMode = signal?["receiveMode"]?.stringValue ?? ""
|
||||||
|
self.signalIgnoreAttachments = signal?["ignoreAttachments"]?.boolValue ?? false
|
||||||
|
self.signalIgnoreStories = signal?["ignoreStories"]?.boolValue ?? false
|
||||||
|
self.signalSendReadReceipts = signal?["sendReadReceipts"]?.boolValue ?? false
|
||||||
|
self.signalAllowFrom = self.stringList(from: signal?["allowFrom"]?.arrayValue)
|
||||||
|
self.signalMediaMaxMb = self.numberString(from: signal?["mediaMaxMb"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||||
|
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||||
|
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||||
|
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||||
|
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||||
|
self.imessageService = imessage?["service"]?.stringValue ?? "auto"
|
||||||
|
self.imessageRegion = imessage?["region"]?.stringValue ?? ""
|
||||||
|
self.imessageAllowFrom = self.stringList(from: imessage?["allowFrom"]?.arrayValue)
|
||||||
|
self.imessageIncludeAttachments = imessage?["includeAttachments"]?.boolValue ?? false
|
||||||
|
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||||
|
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||||
|
let entry = channels[key] as? [String: Any]
|
||||||
|
{
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTelegramConfig() async {
|
||||||
|
guard !self.isSavingConfig else { return }
|
||||||
|
self.isSavingConfig = true
|
||||||
|
defer { self.isSavingConfig = false }
|
||||||
|
if !self.configLoaded {
|
||||||
|
await self.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var telegram: [String: Any] = [:]
|
||||||
|
if !self.isTelegramTokenLocked {
|
||||||
|
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||||
|
}
|
||||||
|
telegram["requireMention"] = NSNull()
|
||||||
|
telegram["groups"] = [
|
||||||
|
"*": [
|
||||||
|
"requireMention": self.telegramRequireMention,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||||
|
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||||
|
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||||
|
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||||
|
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||||
|
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||||
|
|
||||||
|
await self.persistChannelPatch("telegram", payload: telegram)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDiscordConfig() async {
|
||||||
|
guard !self.isSavingConfig else { return }
|
||||||
|
self.isSavingConfig = true
|
||||||
|
defer { self.isSavingConfig = false }
|
||||||
|
if !self.configLoaded {
|
||||||
|
await self.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = self.channelConfigRoot(for: "discord")
|
||||||
|
let discord = self.buildDiscordPatch(base: base)
|
||||||
|
await self.persistChannelPatch("discord", payload: discord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSignalConfig() async {
|
||||||
|
guard !self.isSavingConfig else { return }
|
||||||
|
self.isSavingConfig = true
|
||||||
|
defer { self.isSavingConfig = false }
|
||||||
|
if !self.configLoaded {
|
||||||
|
await self.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var signal: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||||
|
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||||
|
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||||
|
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||||
|
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||||
|
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||||
|
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||||
|
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||||
|
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||||
|
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||||
|
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||||
|
let allow = self.splitCsv(self.signalAllowFrom)
|
||||||
|
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||||
|
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||||
|
|
||||||
|
await self.persistChannelPatch("signal", payload: signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveIMessageConfig() async {
|
||||||
|
guard !self.isSavingConfig else { return }
|
||||||
|
self.isSavingConfig = true
|
||||||
|
defer { self.isSavingConfig = false }
|
||||||
|
if !self.configLoaded {
|
||||||
|
await self.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
var imessage: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||||
|
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||||
|
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||||
|
|
||||||
|
let service = self.trimmed(self.imessageService)
|
||||||
|
if service.isEmpty || service == "auto" {
|
||||||
|
imessage["service"] = NSNull()
|
||||||
|
} else {
|
||||||
|
imessage["service"] = service
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||||
|
|
||||||
|
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||||
|
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||||
|
|
||||||
|
self.setPatchBool(
|
||||||
|
&imessage,
|
||||||
|
key: "includeAttachments",
|
||||||
|
value: self.imessageIncludeAttachments,
|
||||||
|
defaultValue: false)
|
||||||
|
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||||
|
|
||||||
|
await self.persistChannelPatch("imessage", payload: imessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||||
|
var discord: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||||
|
if !self.isDiscordTokenLocked {
|
||||||
|
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dm = self.buildDiscordDmPatch() {
|
||||||
|
discord["dm"] = dm
|
||||||
|
} else {
|
||||||
|
discord["dm"] = NSNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||||
|
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||||
|
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||||
|
|
||||||
|
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||||
|
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||||
|
discord["replyToMode"] = NSNull()
|
||||||
|
} else {
|
||||||
|
discord["replyToMode"] = replyToMode
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||||
|
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||||
|
discord["guilds"] = guilds
|
||||||
|
} else {
|
||||||
|
discord["guilds"] = NSNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actions = self.buildDiscordActionsPatch() {
|
||||||
|
discord["actions"] = actions
|
||||||
|
} else {
|
||||||
|
discord["actions"] = NSNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let slash = self.buildDiscordSlashPatch() {
|
||||||
|
discord["slashCommand"] = slash
|
||||||
|
} else {
|
||||||
|
discord["slashCommand"] = NSNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
return discord
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||||
|
var dm: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||||
|
let allow = self.splitCsv(self.discordAllowFrom)
|
||||||
|
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||||
|
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||||
|
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||||
|
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||||
|
return dm.isEmpty ? nil : dm
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||||
|
if self.discordGuilds.isEmpty {
|
||||||
|
return NSNull()
|
||||||
|
}
|
||||||
|
var patch: [String: Any] = [:]
|
||||||
|
let baseKeys = Set(base.keys)
|
||||||
|
var formKeys = Set<String>()
|
||||||
|
for entry in self.discordGuilds {
|
||||||
|
let key = self.trimmed(entry.key)
|
||||||
|
guard !key.isEmpty else { continue }
|
||||||
|
formKeys.insert(key)
|
||||||
|
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||||
|
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||||
|
}
|
||||||
|
for key in baseKeys.subtracting(formKeys) {
|
||||||
|
patch[key] = NSNull()
|
||||||
|
}
|
||||||
|
return patch.isEmpty ? NSNull() : patch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||||
|
var payload: [String: Any] = [:]
|
||||||
|
let slug = self.trimmed(entry.slug)
|
||||||
|
if slug.isEmpty {
|
||||||
|
payload["slug"] = NSNull()
|
||||||
|
} else {
|
||||||
|
payload["slug"] = slug
|
||||||
|
}
|
||||||
|
if entry.requireMention {
|
||||||
|
payload["requireMention"] = true
|
||||||
|
} else {
|
||||||
|
payload["requireMention"] = NSNull()
|
||||||
|
}
|
||||||
|
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||||
|
payload["reactionNotifications"] = entry.reactionNotifications
|
||||||
|
} else {
|
||||||
|
payload["reactionNotifications"] = NSNull()
|
||||||
|
}
|
||||||
|
let users = self.splitCsv(entry.users)
|
||||||
|
self.setPatchList(&payload, key: "users", values: users)
|
||||||
|
|
||||||
|
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||||
|
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||||
|
payload["channels"] = channels
|
||||||
|
} else {
|
||||||
|
payload["channels"] = NSNull()
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||||
|
if forms.isEmpty {
|
||||||
|
return NSNull()
|
||||||
|
}
|
||||||
|
var patch: [String: Any] = [:]
|
||||||
|
let baseKeys = Set(base.keys)
|
||||||
|
var formKeys = Set<String>()
|
||||||
|
for channel in forms {
|
||||||
|
let channelKey = self.trimmed(channel.key)
|
||||||
|
guard !channelKey.isEmpty else { continue }
|
||||||
|
formKeys.insert(channelKey)
|
||||||
|
var channelPayload: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||||
|
self.setPatchBool(
|
||||||
|
&channelPayload,
|
||||||
|
key: "requireMention",
|
||||||
|
value: channel.requireMention,
|
||||||
|
defaultValue: false)
|
||||||
|
patch[channelKey] = channelPayload
|
||||||
|
}
|
||||||
|
for key in baseKeys.subtracting(formKeys) {
|
||||||
|
patch[key] = NSNull()
|
||||||
|
}
|
||||||
|
return patch.isEmpty ? NSNull() : patch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||||
|
var actions: [String: Any] = [:]
|
||||||
|
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "permissions", value: self.discordActionPermissions, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "messages", value: self.discordActionMessages, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "threads", value: self.discordActionThreads, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "pins", value: self.discordActionPins, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "search", value: self.discordActionSearch, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "events", value: self.discordActionEvents, defaultValue: true)
|
||||||
|
self.setAction(&actions, key: "roles", value: self.discordActionRoles, defaultValue: false)
|
||||||
|
self.setAction(&actions, key: "moderation", value: self.discordActionModeration, defaultValue: false)
|
||||||
|
return actions.isEmpty ? nil : actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||||
|
var slash: [String: Any] = [:]
|
||||||
|
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||||
|
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||||
|
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||||
|
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||||
|
return slash.isEmpty ? nil : slash
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||||
|
do {
|
||||||
|
guard let baseHash = self.configHash else {
|
||||||
|
self.configStatus = "Config hash missing; reload and retry."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let data = try JSONSerialization.data(
|
||||||
|
withJSONObject: ["channels": [channelId: payload]],
|
||||||
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
|
self.configStatus = "Failed to encode config."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"raw": AnyCodable(raw),
|
||||||
|
"baseHash": AnyCodable(baseHash),
|
||||||
|
]
|
||||||
|
_ = try await GatewayConnection.shared.requestRaw(
|
||||||
|
method: .configPatch,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: 10000)
|
||||||
|
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||||
|
await self.loadConfig()
|
||||||
|
await self.refresh(probe: true)
|
||||||
|
} catch {
|
||||||
|
self.configStatus = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stringList(from values: [AnyCodable]?) -> String {
|
||||||
|
guard let values else { return "" }
|
||||||
|
let strings = values.compactMap { entry -> String? in
|
||||||
|
if let str = entry.stringValue { return str }
|
||||||
|
if let intVal = entry.intValue { return String(intVal) }
|
||||||
|
if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numberString(from value: AnyCodable?) -> String {
|
||||||
|
if let number = value?.doubleValue ?? value?.intValue.map(Double.init) {
|
||||||
|
return String(Int(number))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replyMode(from value: String?) -> String {
|
||||||
|
if let value, ["off", "first", "all"].contains(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitCsv(_ value: String) -> [String] {
|
||||||
|
value
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trimmed(_ value: String) -> String {
|
||||||
|
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||||
|
let trimmed = self.trimmed(value)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
target[key] = NSNull()
|
||||||
|
} else {
|
||||||
|
target[key] = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||||
|
let trimmed = self.trimmed(value)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
target[key] = NSNull()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let number = Double(trimmed) {
|
||||||
|
target[key] = number
|
||||||
|
} else {
|
||||||
|
target[key] = NSNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPatchInt(
|
||||||
|
_ target: inout [String: Any],
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
allowZero: Bool)
|
||||||
|
{
|
||||||
|
let trimmed = self.trimmed(value)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
target[key] = NSNull()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let number = Int(trimmed) else {
|
||||||
|
target[key] = NSNull()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let isValid = allowZero ? number >= 0 : number > 0
|
||||||
|
guard isValid else {
|
||||||
|
target[key] = NSNull()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target[key] = number
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPatchBool(
|
||||||
|
_ target: inout [String: Any],
|
||||||
|
key: String,
|
||||||
|
value: Bool,
|
||||||
|
defaultValue: Bool)
|
||||||
|
{
|
||||||
|
if value == defaultValue {
|
||||||
|
target[key] = NSNull()
|
||||||
|
} else {
|
||||||
|
target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||||
|
if values.isEmpty {
|
||||||
|
target[key] = NSNull()
|
||||||
|
} else {
|
||||||
|
target[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setAction(
|
||||||
|
_ actions: inout [String: Any],
|
||||||
|
key: String,
|
||||||
|
value: Bool,
|
||||||
|
defaultValue: Bool)
|
||||||
|
{
|
||||||
|
if value == defaultValue {
|
||||||
|
actions[key] = NSNull()
|
||||||
|
} else {
|
||||||
|
actions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -1,14 +1,13 @@
|
|||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension ChannelsStore {
|
extension ConnectionsStore {
|
||||||
func start() {
|
func start() {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
guard self.pollTask == nil else { return }
|
guard self.pollTask == nil else { return }
|
||||||
self.pollTask = Task.detached { [weak self] in
|
self.pollTask = Task.detached { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refresh(probe: true)
|
await self.refresh(probe: true)
|
||||||
await self.loadConfigSchema()
|
|
||||||
await self.loadConfig()
|
await self.loadConfig()
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||||
+101
-8
@@ -187,10 +187,49 @@ struct ConfigSnapshot: Codable {
|
|||||||
let issues: [Issue]?
|
let issues: [Issue]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DiscordGuildChannelForm: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var key: String
|
||||||
|
var allow: Bool
|
||||||
|
var requireMention: Bool
|
||||||
|
|
||||||
|
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
|
||||||
|
self.key = key
|
||||||
|
self.allow = allow
|
||||||
|
self.requireMention = requireMention
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscordGuildForm: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var key: String
|
||||||
|
var slug: String
|
||||||
|
var requireMention: Bool
|
||||||
|
var reactionNotifications: String
|
||||||
|
var users: String
|
||||||
|
var channels: [DiscordGuildChannelForm]
|
||||||
|
|
||||||
|
init(
|
||||||
|
key: String = "",
|
||||||
|
slug: String = "",
|
||||||
|
requireMention: Bool = false,
|
||||||
|
reactionNotifications: String = "own",
|
||||||
|
users: String = "",
|
||||||
|
channels: [DiscordGuildChannelForm] = [])
|
||||||
|
{
|
||||||
|
self.key = key
|
||||||
|
self.slug = slug
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.reactionNotifications = reactionNotifications
|
||||||
|
self.users = users
|
||||||
|
self.channels = channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class ChannelsStore {
|
final class ConnectionsStore {
|
||||||
static let shared = ChannelsStore()
|
static let shared = ConnectionsStore()
|
||||||
|
|
||||||
var snapshot: ChannelsStatusSnapshot?
|
var snapshot: ChannelsStatusSnapshot?
|
||||||
var lastError: String?
|
var lastError: String?
|
||||||
@@ -201,21 +240,75 @@ final class ChannelsStore {
|
|||||||
var whatsappLoginQrDataUrl: String?
|
var whatsappLoginQrDataUrl: String?
|
||||||
var whatsappLoginConnected: Bool?
|
var whatsappLoginConnected: Bool?
|
||||||
var whatsappBusy = false
|
var whatsappBusy = false
|
||||||
var telegramBusy = false
|
|
||||||
|
|
||||||
|
var telegramToken: String = ""
|
||||||
|
var telegramRequireMention = true
|
||||||
|
var telegramAllowFrom: String = ""
|
||||||
|
var telegramProxy: String = ""
|
||||||
|
var telegramWebhookUrl: String = ""
|
||||||
|
var telegramWebhookSecret: String = ""
|
||||||
|
var telegramWebhookPath: String = ""
|
||||||
|
var telegramBusy = false
|
||||||
|
var discordEnabled = true
|
||||||
|
var discordToken: String = ""
|
||||||
|
var discordDmEnabled = true
|
||||||
|
var discordAllowFrom: String = ""
|
||||||
|
var discordGroupEnabled = false
|
||||||
|
var discordGroupChannels: String = ""
|
||||||
|
var discordMediaMaxMb: String = ""
|
||||||
|
var discordHistoryLimit: String = ""
|
||||||
|
var discordTextChunkLimit: String = ""
|
||||||
|
var discordReplyToMode: String = "off"
|
||||||
|
var discordGuilds: [DiscordGuildForm] = []
|
||||||
|
var discordActionReactions = true
|
||||||
|
var discordActionStickers = true
|
||||||
|
var discordActionPolls = true
|
||||||
|
var discordActionPermissions = true
|
||||||
|
var discordActionMessages = true
|
||||||
|
var discordActionThreads = true
|
||||||
|
var discordActionPins = true
|
||||||
|
var discordActionSearch = true
|
||||||
|
var discordActionMemberInfo = true
|
||||||
|
var discordActionRoleInfo = true
|
||||||
|
var discordActionChannelInfo = true
|
||||||
|
var discordActionVoiceStatus = true
|
||||||
|
var discordActionEvents = true
|
||||||
|
var discordActionRoles = false
|
||||||
|
var discordActionModeration = false
|
||||||
|
var discordSlashEnabled = false
|
||||||
|
var discordSlashName: String = ""
|
||||||
|
var discordSlashSessionPrefix: String = ""
|
||||||
|
var discordSlashEphemeral = true
|
||||||
|
var signalEnabled = true
|
||||||
|
var signalAccount: String = ""
|
||||||
|
var signalHttpUrl: String = ""
|
||||||
|
var signalHttpHost: String = ""
|
||||||
|
var signalHttpPort: String = ""
|
||||||
|
var signalCliPath: String = ""
|
||||||
|
var signalAutoStart = true
|
||||||
|
var signalReceiveMode: String = ""
|
||||||
|
var signalIgnoreAttachments = false
|
||||||
|
var signalIgnoreStories = false
|
||||||
|
var signalSendReadReceipts = false
|
||||||
|
var signalAllowFrom: String = ""
|
||||||
|
var signalMediaMaxMb: String = ""
|
||||||
|
var imessageEnabled = true
|
||||||
|
var imessageCliPath: String = ""
|
||||||
|
var imessageDbPath: String = ""
|
||||||
|
var imessageService: String = "auto"
|
||||||
|
var imessageRegion: String = ""
|
||||||
|
var imessageAllowFrom: String = ""
|
||||||
|
var imessageIncludeAttachments = false
|
||||||
|
var imessageMediaMaxMb: String = ""
|
||||||
var configStatus: String?
|
var configStatus: String?
|
||||||
var isSavingConfig = false
|
var isSavingConfig = false
|
||||||
var configSchemaLoading = false
|
|
||||||
var configSchema: ConfigSchemaNode?
|
|
||||||
var configUiHints: [String: ConfigUiHint] = [:]
|
|
||||||
var configDraft: [String: Any] = [:]
|
|
||||||
var configDirty = false
|
|
||||||
|
|
||||||
let interval: TimeInterval = 45
|
let interval: TimeInterval = 45
|
||||||
let isPreview: Bool
|
let isPreview: Bool
|
||||||
var pollTask: Task<Void, Never>?
|
var pollTask: Task<Void, Never>?
|
||||||
var configRoot: [String: Any] = [:]
|
var configRoot: [String: Any] = [:]
|
||||||
var configLoaded = false
|
var configLoaded = false
|
||||||
|
var configHash: String?
|
||||||
|
|
||||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
@@ -900,7 +900,7 @@ extension DebugSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
configuration.label
|
configuration.label
|
||||||
|
|||||||
@@ -1,607 +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: nil, defaults: nil, 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 {
|
|
||||||
let 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],
|
|
||||||
rawCommand: String?,
|
|
||||||
cwd: String?,
|
|
||||||
env: [String: String]?
|
|
||||||
) -> ExecCommandResolution? {
|
|
||||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
|
||||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
|
||||||
}
|
|
||||||
return self.resolve(command: command, cwd: cwd, env: env)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
|
||||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveExecutable(
|
|
||||||
rawExecutable: String,
|
|
||||||
cwd: String?,
|
|
||||||
env: [String: String]?
|
|
||||||
) -> ExecCommandResolution? {
|
|
||||||
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
|
||||||
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 parseFirstToken(_ command: String) -> String? {
|
|
||||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return nil }
|
|
||||||
guard let first = trimmed.first else { return nil }
|
|
||||||
if first == "\"" || first == "'" {
|
|
||||||
let rest = trimmed.dropFirst()
|
|
||||||
if let end = rest.firstIndex(of: first) {
|
|
||||||
return String(rest[..<end])
|
|
||||||
}
|
|
||||||
return String(rest)
|
|
||||||
}
|
|
||||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
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: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
|
||||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmed.isEmpty { return trimmed }
|
|
||||||
return self.displayString(for: argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,360 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalsPromptPresenter {
|
|
||||||
@MainActor
|
|
||||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
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: @unchecked Sendable {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,6 @@ actor GatewayConnection {
|
|||||||
case configGet = "config.get"
|
case configGet = "config.get"
|
||||||
case configSet = "config.set"
|
case configSet = "config.set"
|
||||||
case configPatch = "config.patch"
|
case configPatch = "config.patch"
|
||||||
case configSchema = "config.schema"
|
|
||||||
case wizardStart = "wizard.start"
|
case wizardStart = "wizard.start"
|
||||||
case wizardNext = "wizard.next"
|
case wizardNext = "wizard.next"
|
||||||
case wizardCancel = "wizard.cancel"
|
case wizardCancel = "wizard.cancel"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ConcurrencyExtras
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -17,13 +16,6 @@ actor GatewayEndpointStore {
|
|||||||
static let shared = GatewayEndpointStore()
|
static let shared = GatewayEndpointStore()
|
||||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
|
||||||
private enum EnvOverrideWarningKind: Sendable {
|
|
||||||
case token
|
|
||||||
case password
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
|
||||||
|
|
||||||
struct Deps: Sendable {
|
struct Deps: Sendable {
|
||||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||||
@@ -38,18 +30,16 @@ actor GatewayEndpointStore {
|
|||||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||||
token: {
|
token: {
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
|
||||||
return GatewayEndpointStore.resolveGatewayToken(
|
return GatewayEndpointStore.resolveGatewayToken(
|
||||||
isRemote: isRemote,
|
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment,
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
|
||||||
return GatewayEndpointStore.resolveGatewayPassword(
|
return GatewayEndpointStore.resolveGatewayPassword(
|
||||||
isRemote: isRemote,
|
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment,
|
env: ProcessInfo.processInfo.environment,
|
||||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||||
@@ -78,14 +68,6 @@ actor GatewayEndpointStore {
|
|||||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
|
||||||
!configPassword.isEmpty
|
|
||||||
{
|
|
||||||
self.warnEnvOverrideOnce(
|
|
||||||
kind: .password,
|
|
||||||
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
|
||||||
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
|
||||||
}
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
if isRemote {
|
||||||
@@ -117,26 +99,6 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
|
||||||
if isRemote {
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
|
||||||
let password = remote["password"] as? String
|
|
||||||
{
|
|
||||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any],
|
|
||||||
let password = auth["password"] as? String
|
|
||||||
{
|
|
||||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveGatewayToken(
|
private static func resolveGatewayToken(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
@@ -146,14 +108,6 @@ actor GatewayEndpointStore {
|
|||||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
|
||||||
!configToken.isEmpty
|
|
||||||
{
|
|
||||||
self.warnEnvOverrideOnce(
|
|
||||||
kind: .token,
|
|
||||||
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
|
||||||
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
|
||||||
}
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
if isRemote {
|
||||||
@@ -185,49 +139,6 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
|
||||||
if isRemote {
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
|
||||||
let token = remote["token"] as? String
|
|
||||||
{
|
|
||||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
|
||||||
let auth = gateway["auth"] as? [String: Any],
|
|
||||||
let token = auth["token"] as? String
|
|
||||||
{
|
|
||||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func warnEnvOverrideOnce(
|
|
||||||
kind: EnvOverrideWarningKind,
|
|
||||||
envVar: String,
|
|
||||||
configKey: String)
|
|
||||||
{
|
|
||||||
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
|
||||||
switch kind {
|
|
||||||
case .token:
|
|
||||||
guard !state.token else { return false }
|
|
||||||
state.token = true
|
|
||||||
return true
|
|
||||||
case .password:
|
|
||||||
guard !state.password else { return false }
|
|
||||||
state.password = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard shouldWarn else { return }
|
|
||||||
Self.staticLogger.warning(
|
|
||||||
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
|
||||||
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private let deps: Deps
|
private let deps: Deps
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ enum GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
_ = bundlePath
|
_ = bundlePath
|
||||||
guard !CommandResolver.connectionModeIsRemote() else {
|
|
||||||
self.logger.info("launchd change skipped (remote mode)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
@@ -116,9 +112,7 @@ extension GatewayLaunchAgentManager {
|
|||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "daemon",
|
subcommand: "daemon",
|
||||||
extraArgs: self.withJsonFlag(args),
|
extraArgs: self.withJsonFlag(args))
|
||||||
// Launchd management must always run locally, even if remote mode is configured.
|
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
|||||||
@@ -114,9 +114,6 @@ final class GatewayProcessManager {
|
|||||||
self.lastFailureReason = nil
|
self.lastFailureReason = nil
|
||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
self.logger.info("gateway stop requested")
|
self.logger.info("gateway stop requested")
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
Task {
|
Task {
|
||||||
_ = await GatewayLaunchAgentManager.set(
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -432,168 +427,42 @@ actor MacNodeRuntime {
|
|||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
|
||||||
|
|
||||||
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 env = Self.sanitizedEnv(params.env)
|
|
||||||
let resolution = ExecCommandResolution.resolve(
|
|
||||||
command: command,
|
|
||||||
rawCommand: params.rawCommand,
|
|
||||||
cwd: params.cwd,
|
|
||||||
env: 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: displayCommand,
|
|
||||||
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)
|
||||||
var approvedByAsk = false
|
switch decision {
|
||||||
if requiresAsk {
|
case .allowOnce:
|
||||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
break
|
||||||
ExecApprovalPromptRequest(
|
case .allowAlways:
|
||||||
command: displayCommand,
|
SystemRunAllowlist.add(command)
|
||||||
cwd: params.cwd,
|
case .deny:
|
||||||
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: displayCommand,
|
|
||||||
reason: "user-denied"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
case nil:
|
|
||||||
if askFallback == .full {
|
|
||||||
approvedByAsk = true
|
|
||||||
} else if askFallback == .allowlist {
|
|
||||||
if allowlistMatch != nil || skillAllow {
|
|
||||||
approvedByAsk = true
|
|
||||||
} else {
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: displayCommand,
|
|
||||||
reason: "approval-required"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: displayCommand,
|
|
||||||
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?:
|
|
||||||
approvedByAsk = true
|
|
||||||
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?:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
let env = Self.sanitizedEnv(params.env)
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: displayCommand,
|
|
||||||
reason: "allowlist-miss"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: agentId,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
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: displayCommand,
|
|
||||||
reason: "permission:screenRecording"))
|
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
@@ -602,33 +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: displayCommand))
|
|
||||||
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]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: "\n")
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.finished",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: displayCommand,
|
|
||||||
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?
|
||||||
@@ -676,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)
|
||||||
@@ -752,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)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -694,10 +694,10 @@ extension OnboardingView {
|
|||||||
systemImage: "bubble.left.and.bubble.right")
|
systemImage: "bubble.left.and.bubble.right")
|
||||||
self.featureActionRow(
|
self.featureActionRow(
|
||||||
title: "Connect WhatsApp or Telegram",
|
title: "Connect WhatsApp or Telegram",
|
||||||
subtitle: "Open Settings → Channels to link channels and monitor status.",
|
subtitle: "Open Settings → Connections to link channels and monitor status.",
|
||||||
systemImage: "link")
|
systemImage: "link")
|
||||||
{
|
{
|
||||||
self.openSettings(tab: .channels)
|
self.openSettings(tab: .connections)
|
||||||
}
|
}
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Try Voice Wake",
|
title: "Try Voice Wake",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
.tag(SettingsTab.general)
|
.tag(SettingsTab.general)
|
||||||
|
|
||||||
ChannelsSettings()
|
ConnectionsSettings()
|
||||||
.tabItem { Label("Channels", systemImage: "link") }
|
.tabItem { Label("Connections", systemImage: "link") }
|
||||||
.tag(SettingsTab.channels)
|
.tag(SettingsTab.connections)
|
||||||
|
|
||||||
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
|
VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)
|
||||||
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
.tabItem { Label("Voice Wake", systemImage: "waveform.circle") }
|
||||||
@@ -176,13 +176,13 @@ struct SettingsRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsTab: CaseIterable {
|
enum SettingsTab: CaseIterable {
|
||||||
case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
case general, connections, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||||
static let windowWidth: CGFloat = 824 // wider
|
static let windowWidth: CGFloat = 824 // wider
|
||||||
static let windowHeight: CGFloat = 790 // +10% (more room)
|
static let windowHeight: CGFloat = 790 // +10% (more room)
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "General"
|
case .general: "General"
|
||||||
case .channels: "Channels"
|
case .connections: "Connections"
|
||||||
case .skills: "Skills"
|
case .skills: "Skills"
|
||||||
case .sessions: "Sessions"
|
case .sessions: "Sessions"
|
||||||
case .cron: "Cron"
|
case .cron: "Cron"
|
||||||
@@ -198,7 +198,7 @@ enum SettingsTab: CaseIterable {
|
|||||||
var systemImage: String {
|
var systemImage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: "gearshape"
|
case .general: "gearshape"
|
||||||
case .channels: "link"
|
case .connections: "link"
|
||||||
case .skills: "sparkles"
|
case .skills: "sparkles"
|
||||||
case .sessions: "clock.arrow.circlepath"
|
case .sessions: "clock.arrow.circlepath"
|
||||||
case .cron: "calendar"
|
case .cron: "calendar"
|
||||||
|
|||||||
@@ -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,401 +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)
|
|
||||||
Picker("Agent", selection: Binding(
|
|
||||||
get: { self.model.selectedAgentId },
|
|
||||||
set: { self.model.selectAgent($0) }))
|
|
||||||
{
|
|
||||||
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
|
||||||
Text(self.model.label(for: id)).tag(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.frame(width: 180, 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(self.model.isDefaultsScope
|
|
||||||
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
|
|
||||||
: "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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.model.isDefaultsScope {
|
|
||||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
|
||||||
Text("Last command: \(lastUsedCommand)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
|
|
||||||
Text("Resolved path: \(lastResolvedPath)")
|
|
||||||
.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 {
|
|
||||||
private static let defaultsScopeId = "__defaults__"
|
|
||||||
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] = []
|
|
||||||
|
|
||||||
var agentPickerIds: [String] {
|
|
||||||
[Self.defaultsScopeId] + self.agentIds
|
|
||||||
}
|
|
||||||
|
|
||||||
var isDefaultsScope: Bool {
|
|
||||||
self.selectedAgentId == Self.defaultsScopeId
|
|
||||||
}
|
|
||||||
|
|
||||||
func label(for id: String) -> String {
|
|
||||||
if id == Self.defaultsScopeId { return "Defaults" }
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
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.selectedAgentId == Self.defaultsScopeId {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
if agentId == Self.defaultsScopeId {
|
|
||||||
let defaults = ExecApprovalsStore.resolveDefaults()
|
|
||||||
self.security = defaults.security
|
|
||||||
self.ask = defaults.ask
|
|
||||||
self.askFallback = defaults.askFallback
|
|
||||||
self.autoAllowSkills = defaults.autoAllowSkills
|
|
||||||
self.entries = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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
|
|
||||||
if self.isDefaultsScope {
|
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
|
||||||
defaults.security = security
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.security = security
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.syncQuickMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAsk(_ ask: ExecAsk) {
|
|
||||||
self.ask = ask
|
|
||||||
if self.isDefaultsScope {
|
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
|
||||||
defaults.ask = ask
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.ask = ask
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.syncQuickMode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAskFallback(_ mode: ExecSecurity) {
|
|
||||||
self.askFallback = mode
|
|
||||||
if self.isDefaultsScope {
|
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
|
||||||
defaults.askFallback = mode
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.askFallback = mode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
|
||||||
self.autoAllowSkills = enabled
|
|
||||||
if self.isDefaultsScope {
|
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
|
||||||
defaults.autoAllowSkills = enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.autoAllowSkills = enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
|
||||||
guard !self.isDefaultsScope else { return }
|
|
||||||
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.isDefaultsScope else { return }
|
|
||||||
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.isDefaultsScope else { return }
|
|
||||||
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.isDefaultsScope {
|
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -347,7 +347,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let sessionkey: String?
|
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -357,7 +356,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
sessionkey: String?,
|
|
||||||
idempotencykey: String
|
idempotencykey: String
|
||||||
) {
|
) {
|
||||||
self.to = to
|
self.to = to
|
||||||
@@ -366,7 +364,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.sessionkey = sessionkey
|
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -376,7 +373,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case sessionkey = "sessionKey"
|
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +427,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let deliver: Bool?
|
public let deliver: Bool?
|
||||||
public let attachments: [AnyCodable]?
|
public let attachments: [AnyCodable]?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -448,7 +443,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
attachments: [AnyCodable]?,
|
attachments: [AnyCodable]?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -464,7 +458,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.deliver = deliver
|
self.deliver = deliver
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -481,7 +474,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case deliver
|
case deliver
|
||||||
case attachments
|
case attachments
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -760,10 +752,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 +765,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 +777,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 +790,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"
|
||||||
@@ -1632,51 +1608,6 @@ public struct LogsTailResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalsGetParams: Codable, Sendable {
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ExecApprovalsSetParams: Codable, Sendable {
|
|
||||||
public let file: [String: AnyCodable]
|
|
||||||
public let basehash: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
file: [String: AnyCodable],
|
|
||||||
basehash: String?
|
|
||||||
) {
|
|
||||||
self.file = file
|
|
||||||
self.basehash = basehash
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case file
|
|
||||||
case basehash = "baseHash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|
||||||
public let path: String
|
|
||||||
public let exists: Bool
|
|
||||||
public let hash: String
|
|
||||||
public let file: [String: AnyCodable]
|
|
||||||
|
|
||||||
public init(
|
|
||||||
path: String,
|
|
||||||
exists: Bool,
|
|
||||||
hash: String,
|
|
||||||
file: [String: AnyCodable]
|
|
||||||
) {
|
|
||||||
self.path = path
|
|
||||||
self.exists = exists
|
|
||||||
self.hash = hash
|
|
||||||
self.file = file
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case path
|
|
||||||
case exists
|
|
||||||
case hash
|
|
||||||
case file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ChatHistoryParams: Codable, Sendable {
|
public struct ChatHistoryParams: Codable, Sendable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -134,27 +128,4 @@ import Testing
|
|||||||
#expect(script.contains("CLI="))
|
#expect(script.contains("CLI="))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
||||||
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
|
||||||
|
|
||||||
let tmp = try makeTempDir()
|
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
|
||||||
|
|
||||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
|
||||||
try self.makeExec(at: clawdbotPath)
|
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(
|
|
||||||
subcommand: "daemon",
|
|
||||||
defaults: defaults,
|
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
|
||||||
|
|
||||||
#expect(cmd.first == clawdbotPath.path)
|
|
||||||
#expect(cmd.count >= 2)
|
|
||||||
if cmd.count >= 2 {
|
|
||||||
#expect(cmd[1] == "daemon")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+14
-7
@@ -5,9 +5,9 @@ import Testing
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ChannelsSettingsSmokeTests {
|
struct ConnectionsSettingsSmokeTests {
|
||||||
@Test func channelsSettingsBuildsBodyWithSnapshot() {
|
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
|
||||||
let store = ChannelsStore(isPreview: true)
|
let store = ConnectionsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ChannelsStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
@@ -84,13 +84,20 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
store.whatsappLoginMessage = "Scan QR"
|
store.whatsappLoginMessage = "Scan QR"
|
||||||
store.whatsappLoginQrDataUrl =
|
store.whatsappLoginQrDataUrl =
|
||||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII="
|
||||||
|
store.telegramToken = "123:abc"
|
||||||
|
store.telegramRequireMention = false
|
||||||
|
store.telegramAllowFrom = "123456789"
|
||||||
|
store.telegramProxy = "socks5://localhost:9050"
|
||||||
|
store.telegramWebhookUrl = "https://example.com/telegram"
|
||||||
|
store.telegramWebhookSecret = "secret"
|
||||||
|
store.telegramWebhookPath = "/telegram"
|
||||||
|
|
||||||
let view = ChannelsSettings(store: store)
|
let view = ConnectionsSettings(store: store)
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func channelsSettingsBuildsBodyWithoutSnapshot() {
|
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
|
||||||
let store = ChannelsStore(isPreview: true)
|
let store = ConnectionsStore(isPreview: true)
|
||||||
store.snapshot = ChannelsStatusSnapshot(
|
store.snapshot = ChannelsStatusSnapshot(
|
||||||
ts: 1_700_000_000_000,
|
ts: 1_700_000_000_000,
|
||||||
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||||
@@ -150,7 +157,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"imessage": "default",
|
"imessage": "default",
|
||||||
])
|
])
|
||||||
|
|
||||||
let view = ChannelsSettings(store: store)
|
let view = ConnectionsSettings(store: store)
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,6 @@ import Testing
|
|||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite struct GatewayEndpointStoreTests {
|
@Suite struct GatewayEndpointStoreTests {
|
||||||
private func makeDefaults() -> UserDefaults {
|
|
||||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
|
||||||
defaults.removePersistentDomain(forName: suiteName)
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||||
let snapshot = LaunchAgentPlistSnapshot(
|
let snapshot = LaunchAgentPlistSnapshot(
|
||||||
programArguments: [],
|
programArguments: [],
|
||||||
@@ -73,70 +66,4 @@ import Testing
|
|||||||
launchdSnapshot: snapshot)
|
launchdSnapshot: snapshot)
|
||||||
#expect(password == "launchd-pass")
|
#expect(password == "launchd-pass")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set("remote", forKey: connectionModeKey)
|
|
||||||
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"mode": " local ",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
|
||||||
#expect(resolved.mode == .local)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func connectionModeResolverTrimsConfigMode() {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set("local", forKey: connectionModeKey)
|
|
||||||
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"mode": " remote ",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
|
||||||
#expect(resolved.mode == .remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set("remote", forKey: connectionModeKey)
|
|
||||||
|
|
||||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
|
||||||
#expect(resolved.mode == .remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set("local", forKey: connectionModeKey)
|
|
||||||
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"mode": "staging",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
|
||||||
#expect(resolved.mode == .local)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
|
||||||
let defaults = self.makeDefaults()
|
|
||||||
defaults.set("local", forKey: connectionModeKey)
|
|
||||||
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"remote": [
|
|
||||||
"url": " ws://umbrel:18789 ",
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
|
||||||
#expect(resolved.mode == .remote)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,32 +20,23 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
|||||||
|
|
||||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||||
public var command: [String]
|
public var command: [String]
|
||||||
public var rawCommand: String?
|
|
||||||
public var cwd: String?
|
public var cwd: String?
|
||||||
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],
|
||||||
rawCommand: String? = nil,
|
|
||||||
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.rawCommand = rawCommand
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
# Clawdbot ACP Bridge
|
|
||||||
|
|
||||||
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
|
||||||
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
|
||||||
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
|
||||||
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
|
||||||
request.
|
|
||||||
|
|
||||||
Key goals:
|
|
||||||
|
|
||||||
- Minimal ACP surface area (stdio, NDJSON).
|
|
||||||
- Stable session mapping across reconnects.
|
|
||||||
- Works with existing Gateway session store (list/resolve/reset).
|
|
||||||
- Safe defaults (isolated ACP session keys by default).
|
|
||||||
|
|
||||||
## How can I use this
|
|
||||||
|
|
||||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
|
||||||
drive a Clawdbot Gateway session.
|
|
||||||
|
|
||||||
Quick steps:
|
|
||||||
|
|
||||||
1. Run a Gateway (local or remote).
|
|
||||||
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
|
||||||
3. Point the IDE to run `clawdbot acp` over stdio.
|
|
||||||
|
|
||||||
Example config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
|
||||||
clawdbot config set gateway.remote.token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Example run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Selecting agents
|
|
||||||
|
|
||||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
|
||||||
|
|
||||||
Use agent-scoped session keys to target a specific agent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp --session agent:main:main
|
|
||||||
clawdbot acp --session agent:design:main
|
|
||||||
clawdbot acp --session agent:qa:bug-123
|
|
||||||
```
|
|
||||||
|
|
||||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
|
||||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
|
||||||
the key or label.
|
|
||||||
|
|
||||||
## Zed editor setup
|
|
||||||
|
|
||||||
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"Clawdbot ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "clawdbot",
|
|
||||||
"args": ["acp"],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To target a specific Gateway or agent:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"Clawdbot ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "clawdbot",
|
|
||||||
"args": [
|
|
||||||
"acp",
|
|
||||||
"--url", "wss://gateway-host:18789",
|
|
||||||
"--token", "<token>",
|
|
||||||
"--session", "agent:design:main"
|
|
||||||
],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
|
||||||
|
|
||||||
## Execution Model
|
|
||||||
|
|
||||||
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
|
||||||
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
|
||||||
- ACP `prompt` translates to Gateway `chat.send`.
|
|
||||||
- Gateway streaming events are translated back into ACP streaming events.
|
|
||||||
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
|
||||||
|
|
||||||
## Session Mapping
|
|
||||||
|
|
||||||
By default each ACP session is mapped to a dedicated Gateway session key:
|
|
||||||
|
|
||||||
- `acp:<uuid>` unless overridden.
|
|
||||||
|
|
||||||
You can override or reuse sessions in two ways:
|
|
||||||
|
|
||||||
1) CLI defaults
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp --session agent:main:main
|
|
||||||
clawdbot acp --session-label "support inbox"
|
|
||||||
clawdbot acp --reset-session
|
|
||||||
```
|
|
||||||
|
|
||||||
2) ACP metadata per session
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"sessionKey": "agent:main:main",
|
|
||||||
"sessionLabel": "support inbox",
|
|
||||||
"resetSession": true,
|
|
||||||
"requireExisting": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- `sessionKey`: direct Gateway session key.
|
|
||||||
- `sessionLabel`: resolve an existing session by label.
|
|
||||||
- `resetSession`: mint a new transcript for the key before first use.
|
|
||||||
- `requireExisting`: fail if the key/label does not exist.
|
|
||||||
|
|
||||||
### Session Listing
|
|
||||||
|
|
||||||
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
|
||||||
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
|
||||||
sessions returned.
|
|
||||||
|
|
||||||
## Prompt Translation
|
|
||||||
|
|
||||||
ACP prompt inputs are converted into a Gateway `chat.send`:
|
|
||||||
|
|
||||||
- `text` and `resource` blocks become prompt text.
|
|
||||||
- `resource_link` with image mime types become attachments.
|
|
||||||
- The working directory can be prefixed into the prompt (default on, can be
|
|
||||||
disabled with `--no-prefix-cwd`).
|
|
||||||
|
|
||||||
Gateway streaming events are translated into ACP `message` and `tool_call`
|
|
||||||
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
|
||||||
|
|
||||||
- `complete` -> `stop`
|
|
||||||
- `aborted` -> `cancel`
|
|
||||||
- `error` -> `error`
|
|
||||||
|
|
||||||
## Auth + Gateway Discovery
|
|
||||||
|
|
||||||
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
|
||||||
|
|
||||||
- `--url` / `--token` / `--password` take precedence.
|
|
||||||
- Otherwise use configured `gateway.remote.*` settings.
|
|
||||||
|
|
||||||
## Operational Notes
|
|
||||||
|
|
||||||
- ACP sessions are stored in memory for the bridge process lifetime.
|
|
||||||
- Gateway session state is persisted by the Gateway itself.
|
|
||||||
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
|
||||||
- ACP runs can be canceled and the active run id is tracked per session.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
|
||||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
|
||||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
|
||||||
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
|
||||||
|
|
||||||
## Related Docs
|
|
||||||
|
|
||||||
- CLI usage: `docs/cli/acp.md`
|
|
||||||
- Session model: `docs/concepts/session.md`
|
|
||||||
- Session management internals: `docs/reference/session-management-compaction.md`
|
|
||||||
@@ -78,7 +78,6 @@ Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
|
|||||||
|
|
||||||
Key behaviors:
|
Key behaviors:
|
||||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||||
- Each run starts a **fresh session id** (no prior conversation carry-over).
|
|
||||||
- A summary is posted to the main session (prefix `Cron`, configurable).
|
- A summary is posted to the main session (prefix `Cron`, configurable).
|
||||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||||
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
|
||||||
@@ -101,9 +100,7 @@ Common `agentTurn` fields:
|
|||||||
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
- `bestEffortDeliver`: avoid failing the job if delivery fails.
|
||||||
|
|
||||||
Isolation options (only for `session=isolated`):
|
Isolation options (only for `session=isolated`):
|
||||||
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
|
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the summary system event in main.
|
||||||
- `postToMainMode`: `summary` (default) or `full`.
|
|
||||||
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
|
|
||||||
|
|
||||||
### Model and thinking overrides
|
### Model and thinking overrides
|
||||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
|||||||
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
|
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot webhooks gmail setup \
|
clawdbot hooks gmail setup \
|
||||||
--account clawdbot@gmail.com
|
--account clawdbot@gmail.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- Uses Tailscale Funnel for the public push endpoint.
|
- Uses Tailscale Funnel for the public push endpoint.
|
||||||
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
- Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||||
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
|
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
|
||||||
|
|
||||||
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
|
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
|
||||||
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
|
|||||||
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot webhooks gmail run
|
clawdbot hooks gmail run
|
||||||
```
|
```
|
||||||
|
|
||||||
## One-time setup
|
## One-time setup
|
||||||
@@ -191,7 +191,7 @@ Notes:
|
|||||||
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
|
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
|
||||||
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
|
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
|
||||||
|
|
||||||
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch.
|
Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
|
||||||
|
|
||||||
## Expose the handler (advanced, unsupported)
|
## Expose the handler (advanced, unsupported)
|
||||||
|
|
||||||
|
|||||||
@@ -16,19 +16,19 @@ read_when:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# WhatsApp
|
# WhatsApp
|
||||||
clawdbot message poll --target +15555550123 \
|
clawdbot message poll --to +15555550123 \
|
||||||
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
|
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
|
||||||
clawdbot message poll --target 123456789@g.us \
|
clawdbot message poll --to 123456789@g.us \
|
||||||
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
|
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
clawdbot message poll --channel discord --target channel:123456789 \
|
clawdbot message poll --channel discord --to channel:123456789 \
|
||||||
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
|
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
|
||||||
clawdbot message poll --channel discord --target channel:123456789 \
|
clawdbot message poll --channel discord --to channel:123456789 \
|
||||||
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
||||||
|
|
||||||
# MS Teams
|
# MS Teams
|
||||||
clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \
|
clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
|
||||||
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Mapping options (summary):
|
|||||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||||
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||||
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||||
|
|
||||||
## Responses
|
## Responses
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -13,7 +13,6 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
2) Set the token for Clawdbot:
|
2) Set the token for Clawdbot:
|
||||||
- Env: `DISCORD_BOT_TOKEN=...`
|
- Env: `DISCORD_BOT_TOKEN=...`
|
||||||
- Or config: `channels.discord.token: "..."`.
|
- Or config: `channels.discord.token: "..."`.
|
||||||
- If both are set, config takes precedence (env fallback is default-account only).
|
|
||||||
3) Invite the bot to your server with message permissions.
|
3) Invite the bot to your server with message permissions.
|
||||||
4) Start the gateway.
|
4) Start the gateway.
|
||||||
5) DM access is pairing by default; approve the pairing code on first contact.
|
5) DM access is pairing by default; approve the pairing code on first contact.
|
||||||
@@ -39,8 +38,8 @@ Minimal config:
|
|||||||
## How it works
|
## How it works
|
||||||
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
||||||
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
||||||
3. Configure Clawdbot with `channels.discord.token` (or `DISCORD_BOT_TOKEN` as a fallback).
|
3. Configure Clawdbot with `DISCORD_BOT_TOKEN` (or `channels.discord.token` in `~/.clawdbot/clawdbot.json`).
|
||||||
4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
|
4. Run the gateway; it auto-starts the Discord channel when a token is available (env or config) and `channels.discord.enabled` is not `false`.
|
||||||
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
|
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
|
||||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
|
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
|
||||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||||
@@ -58,7 +57,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 +174,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 +191,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 +360,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:
|
||||||
|
|||||||
@@ -108,68 +108,10 @@ If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrappe
|
|||||||
Example wrapper:
|
Example wrapper:
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
exec ssh -T gateway-host imsg "$@"
|
exec ssh -T mac-mini imsg "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Remote attachments:** When `cliPath` points to a remote host via SSH, attachment paths in the Messages database reference files on the remote machine. Clawdbot can automatically fetch these over SCP by setting `channels.imessage.remoteHost`:
|
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
imessage: {
|
|
||||||
cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac
|
|
||||||
remoteHost: "user@gateway-host", // for SCP file transfer
|
|
||||||
includeAttachments: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `remoteHost` is not set, Clawdbot attempts to auto-detect it by parsing the SSH command in your wrapper script. Explicit configuration is recommended for reliability.
|
|
||||||
|
|
||||||
#### Remote Mac via Tailscale (example)
|
|
||||||
If the Gateway runs on a Linux host/VM but iMessage must run on a Mac, Tailscale is the simplest bridge: the Gateway talks to the Mac over the tailnet, runs `imsg` via SSH, and SCPs attachments back.
|
|
||||||
|
|
||||||
Architecture:
|
|
||||||
```
|
|
||||||
┌──────────────────────────────┐ SSH (imsg rpc) ┌──────────────────────────┐
|
|
||||||
│ Gateway host (Linux/VM) │──────────────────────────────────▶│ Mac with Messages + imsg │
|
|
||||||
│ - clawdbot gateway │ SCP (attachments) │ - Messages signed in │
|
|
||||||
│ - channels.imessage.cliPath │◀──────────────────────────────────│ - Remote Login enabled │
|
|
||||||
└──────────────────────────────┘ └──────────────────────────┘
|
|
||||||
▲
|
|
||||||
│ Tailscale tailnet (hostname or 100.x.y.z)
|
|
||||||
▼
|
|
||||||
user@gateway-host
|
|
||||||
```
|
|
||||||
|
|
||||||
Concrete config example (Tailscale hostname):
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
imessage: {
|
|
||||||
enabled: true,
|
|
||||||
cliPath: "~/.clawdbot/scripts/imsg-ssh",
|
|
||||||
remoteHost: "bot@mac-mini.tailnet-1234.ts.net",
|
|
||||||
includeAttachments: true,
|
|
||||||
dbPath: "/Users/bot/Library/Messages/chat.db"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example wrapper (`~/.clawdbot/scripts/imsg-ssh`):
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Ensure the Mac is signed in to Messages, and Remote Login is enabled.
|
|
||||||
- Use SSH keys so `ssh bot@mac-mini.tailnet-1234.ts.net` works without prompts.
|
|
||||||
- `remoteHost` should match the SSH target so SCP can fetch attachments.
|
|
||||||
|
|
||||||
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don't commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
@@ -240,7 +182,6 @@ Provider options:
|
|||||||
- `channels.imessage.enabled`: enable/disable channel startup.
|
- `channels.imessage.enabled`: enable/disable channel startup.
|
||||||
- `channels.imessage.cliPath`: path to `imsg`.
|
- `channels.imessage.cliPath`: path to `imsg`.
|
||||||
- `channels.imessage.dbPath`: Messages DB path.
|
- `channels.imessage.dbPath`: Messages DB path.
|
||||||
- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `user@gateway-host`). Auto-detected from SSH wrapper if not set.
|
|
||||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||||
- `channels.imessage.region`: SMS region.
|
- `channels.imessage.region`: SMS region.
|
||||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ 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).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ Details: [Plugins](/plugin)
|
|||||||
2) Configure credentials:
|
2) Configure credentials:
|
||||||
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
- Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`)
|
||||||
- Or config: `channels.matrix.*`
|
- Or config: `channels.matrix.*`
|
||||||
- If both are set, config takes precedence.
|
|
||||||
3) Restart the gateway (or finish onboarding).
|
3) Restart the gateway (or finish onboarding).
|
||||||
4) DM access defaults to pairing; approve the pairing code on first contact.
|
4) DM access defaults to pairing; approve the pairing code on first contact.
|
||||||
|
|
||||||
@@ -70,10 +69,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 +85,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).
|
||||||
@@ -474,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
|
|||||||
## Polls (Adaptive Cards)
|
## Polls (Adaptive Cards)
|
||||||
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
||||||
|
|
||||||
- CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...`
|
- CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...`
|
||||||
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
|
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
|
||||||
- The gateway must stay online to record votes.
|
- The gateway must stay online to record votes.
|
||||||
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
||||||
|
|||||||
@@ -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"`.
|
||||||
|
|||||||
+15
-46
@@ -13,7 +13,6 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
|
|||||||
2) Set the token:
|
2) Set the token:
|
||||||
- Env: `TELEGRAM_BOT_TOKEN=...`
|
- Env: `TELEGRAM_BOT_TOKEN=...`
|
||||||
- Or config: `channels.telegram.botToken: "..."`.
|
- Or config: `channels.telegram.botToken: "..."`.
|
||||||
- If both are set, config takes precedence (env fallback is default-account only).
|
|
||||||
3) Start the gateway.
|
3) Start the gateway.
|
||||||
4) DM access is pairing by default; approve the pairing code on first contact.
|
4) DM access is pairing by default; approve the pairing code on first contact.
|
||||||
|
|
||||||
@@ -62,11 +61,10 @@ Example:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
|
Env option: `TELEGRAM_BOT_TOKEN=...` (works for the default account).
|
||||||
If both env and config are set, config takes precedence.
|
|
||||||
|
|
||||||
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
3) Start the gateway. Telegram starts when a token is resolved (config first, env fallback).
|
3) Start the gateway. Telegram starts when a token is resolved (env or config).
|
||||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||||
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
|
5) For groups: add the bot, decide privacy/admin behavior (below), then set `channels.telegram.groups` to control mention gating + allowlists.
|
||||||
|
|
||||||
@@ -152,7 +150,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,21 +214,18 @@ 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.
|
||||||
|
|
||||||
## Inline Buttons
|
## Inline Buttons
|
||||||
|
|
||||||
Telegram supports inline keyboards with callback buttons.
|
Telegram supports inline keyboards with callback buttons. Enable this feature via capabilities:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
"channels": {
|
"channels": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"capabilities": {
|
"capabilities": ["inlineButtons"]
|
||||||
"inlineButtons": "allowlist"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,9 +238,7 @@ For per-account configuration:
|
|||||||
"telegram": {
|
"telegram": {
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"main": {
|
"main": {
|
||||||
"capabilities": {
|
"capabilities": ["inlineButtons"]
|
||||||
"inlineButtons": "allowlist"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,16 +246,6 @@ For per-account configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Scopes:
|
|
||||||
- `off` — inline buttons disabled
|
|
||||||
- `dm` — only DMs (group targets blocked)
|
|
||||||
- `group` — only groups (DM targets blocked)
|
|
||||||
- `all` — DMs + groups
|
|
||||||
- `allowlist` — DMs + groups, but only senders allowed by `allowFrom`/`groupAllowFrom` (same rules as control commands)
|
|
||||||
|
|
||||||
Default: `allowlist`.
|
|
||||||
Legacy: `capabilities: ["inlineButtons"]` = `inlineButtons: "all"`.
|
|
||||||
|
|
||||||
### Sending buttons
|
### Sending buttons
|
||||||
|
|
||||||
Use the message tool with the `buttons` parameter:
|
Use the message tool with the `buttons` parameter:
|
||||||
@@ -291,12 +273,12 @@ When a user clicks a button, the callback data is sent back to the agent as a me
|
|||||||
|
|
||||||
### Configuration options
|
### Configuration options
|
||||||
|
|
||||||
Telegram capabilities can be configured at two levels (object form shown above; legacy string arrays still supported):
|
Telegram capabilities can be configured at two levels:
|
||||||
|
|
||||||
- `channels.telegram.capabilities`: Global default capability config applied to all Telegram accounts unless overridden.
|
- `channels.telegram.capabilities`: Global default capability list applied to all Telegram accounts unless overridden.
|
||||||
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override the global defaults for that specific account.
|
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities that override or extend the global defaults for that specific account.
|
||||||
|
|
||||||
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups).
|
Use the global setting when all Telegram bots/accounts should behave the same. Use per-account configuration when different bots need different behaviors (for example, one account only handles DMs while another is allowed in groups or has extra capabilities).
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
|
|
||||||
### DM access
|
### DM access
|
||||||
@@ -362,19 +344,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
|
||||||
@@ -428,8 +397,8 @@ The agent sees reactions as **system notifications** in the conversation history
|
|||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
|
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
|
||||||
- `"off"` — ignore all reactions
|
- `"off"` — ignore all reactions (default when not set)
|
||||||
- `"own"` — notify when users react to bot messages (best-effort; in-memory) (default)
|
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
|
||||||
- `"all"` — notify for all reactions
|
- `"all"` — notify for all reactions
|
||||||
|
|
||||||
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
|
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
|
||||||
@@ -459,7 +428,7 @@ The agent sees reactions as **system notifications** in the conversation history
|
|||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
- Use a chat id (`123456789`) or a username (`@name`) as the target.
|
||||||
- Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`.
|
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -508,8 +477,8 @@ Provider options:
|
|||||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
- `channels.telegram.capabilities`: Enable channel features (e.g., "inlineButtons").
|
||||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
- `channels.telegram.accounts.<account>.capabilities`: Per-account capabilities.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
@@ -522,8 +491,8 @@ Provider options:
|
|||||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
|
||||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
|
||||||
|
|||||||
@@ -82,13 +82,15 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
|
|||||||
"selfChatMode": true,
|
"selfChatMode": true,
|
||||||
"dmPolicy": "allowlist",
|
"dmPolicy": "allowlist",
|
||||||
"allowFrom": ["+15551234567"]
|
"allowFrom": ["+15551234567"]
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"responsePrefix": "[clawdbot]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Self-chat replies default to `[{identity.name}]` when set (otherwise `[clawdbot]`)
|
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix
|
||||||
if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
|
on outbound replies.
|
||||||
the prefix (use `""` to remove it).
|
|
||||||
|
|
||||||
### Number sourcing tips
|
### Number sourcing tips
|
||||||
- **Local eSIM** from your country's mobile carrier (most reliable)
|
- **Local eSIM** from your country's mobile carrier (most reliable)
|
||||||
@@ -202,9 +204,9 @@ The wizard uses it to set your **allowlist/owner** so your own DMs are permitted
|
|||||||
- `always`: always triggers.
|
- `always`: always triggers.
|
||||||
- `/activation mention|always` is owner-only and must be sent as a standalone message.
|
- `/activation mention|always` is owner-only and must be sent as a standalone message.
|
||||||
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
|
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
|
||||||
- **History injection** (pending-only):
|
- **History injection**:
|
||||||
- Recent *unprocessed* messages (default 50) inserted under:
|
- Recent messages (default 50) inserted under:
|
||||||
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
|
`[Chat messages since your last reply - for context]`
|
||||||
- Current message under:
|
- Current message under:
|
||||||
`[Current message - respond to this]`
|
`[Current message - respond to this]`
|
||||||
- Sender suffix appended: `[from: Name (+E164)]`
|
- Sender suffix appended: `[from: Name (+E164)]`
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
|||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- Use a chat id as the target.
|
- Use a chat id as the target.
|
||||||
- Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`.
|
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
|
|
||||||
read_when:
|
|
||||||
- Setting up Zalo Personal for Clawdbot
|
|
||||||
- Debugging Zalo Personal login or message flow
|
|
||||||
---
|
|
||||||
# Zalo Personal (unofficial)
|
|
||||||
|
|
||||||
Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
|
|
||||||
|
|
||||||
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
|
|
||||||
|
|
||||||
## Plugin required
|
|
||||||
Zalo Personal ships as a plugin and is not bundled with the core install.
|
|
||||||
- Install via CLI: `clawdbot plugins install @clawdbot/zalouser`
|
|
||||||
- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser`
|
|
||||||
- Details: [Plugins](/plugin)
|
|
||||||
|
|
||||||
## Prerequisite: zca-cli
|
|
||||||
The Gateway machine must have the `zca` binary available in `PATH`.
|
|
||||||
|
|
||||||
- Verify: `zca --version`
|
|
||||||
- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
|
|
||||||
|
|
||||||
## Quick setup (beginner)
|
|
||||||
1) Install the plugin (see above).
|
|
||||||
2) Login (QR, on the Gateway machine):
|
|
||||||
- `clawdbot channels login --channel zalouser`
|
|
||||||
- Scan the QR code in the terminal with the Zalo mobile app.
|
|
||||||
3) Enable the channel:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
zalouser: {
|
|
||||||
enabled: true,
|
|
||||||
dmPolicy: "pairing"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4) Restart the Gateway (or finish onboarding).
|
|
||||||
5) DM access defaults to pairing; approve the pairing code on first contact.
|
|
||||||
|
|
||||||
## What it is
|
|
||||||
- Uses `zca listen` to receive inbound messages.
|
|
||||||
- Uses `zca msg ...` to send replies (text/media/link).
|
|
||||||
- Designed for “personal account” use cases where Zalo Bot API is not available.
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration.
|
|
||||||
|
|
||||||
## Finding IDs (directory)
|
|
||||||
Use the directory CLI to discover peers/groups and their IDs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot directory self --channel zalouser
|
|
||||||
clawdbot directory peers list --channel zalouser --query "name"
|
|
||||||
clawdbot directory groups list --channel zalouser --query "work"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Limits
|
|
||||||
- Outbound text is chunked to ~2000 characters (Zalo client limits).
|
|
||||||
- Streaming is blocked by default.
|
|
||||||
|
|
||||||
## Access control (DMs)
|
|
||||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
|
||||||
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
|
||||||
|
|
||||||
Approve via:
|
|
||||||
- `clawdbot pairing list zalouser`
|
|
||||||
- `clawdbot pairing approve zalouser <code>`
|
|
||||||
|
|
||||||
## Group access (optional)
|
|
||||||
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
|
||||||
- Restrict to an allowlist with:
|
|
||||||
- `channels.zalouser.groupPolicy = "allowlist"`
|
|
||||||
- `channels.zalouser.groups` (keys are group IDs or names)
|
|
||||||
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
|
||||||
- The configure wizard can prompt for group allowlists.
|
|
||||||
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
zalouser: {
|
|
||||||
groupPolicy: "allowlist",
|
|
||||||
groups: {
|
|
||||||
"123456789": { allow: true },
|
|
||||||
"Work Chat": { allow: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-account
|
|
||||||
Accounts map to zca profiles. Example:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
zalouser: {
|
|
||||||
enabled: true,
|
|
||||||
defaultAccount: "default",
|
|
||||||
accounts: {
|
|
||||||
work: { enabled: true, profile: "work" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**`zca` not found:**
|
|
||||||
- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
|
|
||||||
|
|
||||||
**Login doesn’t stick:**
|
|
||||||
- `clawdbot channels status --probe`
|
|
||||||
- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser`
|
|
||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Run the ACP bridge for IDE integrations"
|
|
||||||
read_when:
|
|
||||||
- Setting up ACP-based IDE integrations
|
|
||||||
- Debugging ACP session routing to the Gateway
|
|
||||||
---
|
|
||||||
|
|
||||||
# acp
|
|
||||||
|
|
||||||
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
|
||||||
|
|
||||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
|
||||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp
|
|
||||||
|
|
||||||
# Remote Gateway
|
|
||||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
|
||||||
|
|
||||||
# Attach to an existing session key
|
|
||||||
clawdbot acp --session agent:main:main
|
|
||||||
|
|
||||||
# Attach by label (must already exist)
|
|
||||||
clawdbot acp --session-label "support inbox"
|
|
||||||
|
|
||||||
# Reset the session key before the first prompt
|
|
||||||
clawdbot acp --session agent:main:main --reset-session
|
|
||||||
```
|
|
||||||
|
|
||||||
## ACP client (debug)
|
|
||||||
|
|
||||||
Use the built-in ACP client to sanity-check the bridge without an IDE.
|
|
||||||
It spawns the ACP bridge and lets you type prompts interactively.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp client
|
|
||||||
|
|
||||||
# Point the spawned bridge at a remote Gateway
|
|
||||||
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
|
|
||||||
|
|
||||||
# Override the server command (default: clawdbot)
|
|
||||||
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to use this
|
|
||||||
|
|
||||||
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
|
||||||
it to drive a Clawdbot Gateway session.
|
|
||||||
|
|
||||||
1. Ensure the Gateway is running (local or remote).
|
|
||||||
2. Configure the Gateway target (config or flags).
|
|
||||||
3. Point your IDE to run `clawdbot acp` over stdio.
|
|
||||||
|
|
||||||
Example config (persisted):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
|
||||||
clawdbot config set gateway.remote.token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Example direct run (no config write):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Selecting agents
|
|
||||||
|
|
||||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
|
||||||
|
|
||||||
Use agent-scoped session keys to target a specific agent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot acp --session agent:main:main
|
|
||||||
clawdbot acp --session agent:design:main
|
|
||||||
clawdbot acp --session agent:qa:bug-123
|
|
||||||
```
|
|
||||||
|
|
||||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
|
||||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
|
||||||
the key or label.
|
|
||||||
|
|
||||||
## Zed editor setup
|
|
||||||
|
|
||||||
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"Clawdbot ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "clawdbot",
|
|
||||||
"args": ["acp"],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To target a specific Gateway or agent:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agent_servers": {
|
|
||||||
"Clawdbot ACP": {
|
|
||||||
"type": "custom",
|
|
||||||
"command": "clawdbot",
|
|
||||||
"args": [
|
|
||||||
"acp",
|
|
||||||
"--url", "wss://gateway-host:18789",
|
|
||||||
"--token", "<token>",
|
|
||||||
"--session", "agent:design:main"
|
|
||||||
],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
|
||||||
|
|
||||||
## Session mapping
|
|
||||||
|
|
||||||
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
|
||||||
To reuse a known session, pass a session key or label:
|
|
||||||
|
|
||||||
- `--session <key>`: use a specific Gateway session key.
|
|
||||||
- `--session-label <label>`: resolve an existing session by label.
|
|
||||||
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
|
||||||
|
|
||||||
If your ACP client supports metadata, you can override per session:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"sessionKey": "agent:main:main",
|
|
||||||
"sessionLabel": "support inbox",
|
|
||||||
"resetSession": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Learn more about session keys at [/concepts/session](/concepts/session).
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
|
||||||
- `--token <token>`: Gateway auth token.
|
|
||||||
- `--password <password>`: Gateway auth password.
|
|
||||||
- `--session <key>`: default session key.
|
|
||||||
- `--session-label <label>`: default session label to resolve.
|
|
||||||
- `--require-existing`: fail if the session key/label does not exist.
|
|
||||||
- `--reset-session`: reset the session key before first use.
|
|
||||||
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
|
||||||
- `--verbose, -v`: verbose logging to stderr.
|
|
||||||
|
|
||||||
### `acp client` options
|
|
||||||
|
|
||||||
- `--cwd <dir>`: working directory for the ACP session.
|
|
||||||
- `--server <command>`: ACP server command (default: `clawdbot`).
|
|
||||||
- `--server-args <args...>`: extra arguments passed to the ACP server.
|
|
||||||
- `--server-verbose`: enable verbose logging on the ACP server.
|
|
||||||
- `--verbose, -v`: verbose client logging.
|
|
||||||
@@ -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.
|
|
||||||
@@ -15,7 +15,6 @@ the configure wizard (same as `clawdbot configure`).
|
|||||||
clawdbot config get browser.executablePath
|
clawdbot config get browser.executablePath
|
||||||
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
clawdbot config set agents.defaults.heartbeat.every "2h"
|
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
|
||||||
clawdbot config unset tools.web.search.apiKey
|
clawdbot config unset tools.web.search.apiKey
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,13 +27,6 @@ clawdbot config get agents.defaults.workspace
|
|||||||
clawdbot config get agents.list[0].id
|
clawdbot config get agents.list[0].id
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the agent list index to target a specific agent:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config get agents.list
|
|
||||||
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Values
|
## Values
|
||||||
|
|
||||||
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot directory` (self, peers, groups)"
|
|
||||||
read_when:
|
|
||||||
- You want to look up contacts/groups/self ids for a channel
|
|
||||||
- You are developing a channel directory adapter
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot directory`
|
|
||||||
|
|
||||||
Directory lookups for channels that support it (contacts/peers, groups, and “me”).
|
|
||||||
|
|
||||||
## Common flags
|
|
||||||
- `--channel <name>`: channel id/alias (required when multiple channels are configured; auto when only one is configured)
|
|
||||||
- `--account <id>`: account id (default: channel default)
|
|
||||||
- `--json`: output JSON
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`).
|
|
||||||
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
|
|
||||||
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
|
|
||||||
|
|
||||||
## Using results with `message send`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot directory peers list --channel slack --query "U0"
|
|
||||||
clawdbot message send --channel slack --target user:U012ABCDEF --message "hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
## ID formats (by channel)
|
|
||||||
|
|
||||||
- WhatsApp: `+15551234567` (DM), `1234567890-1234567890@g.us` (group)
|
|
||||||
- Telegram: `@username` or numeric chat id; groups are numeric ids
|
|
||||||
- Slack: `user:U…` and `channel:C…`
|
|
||||||
- Discord: `user:<id>` and `channel:<id>`
|
|
||||||
- Matrix (plugin): `user:@user:server`, `room:!roomId:server`, or `#alias:server`
|
|
||||||
- Microsoft Teams (plugin): `user:<id>` and `conversation:<id>`
|
|
||||||
- Zalo (plugin): user id (Bot API)
|
|
||||||
- Zalo Personal / `zalouser` (plugin): thread id (DM/group) from `zca` (`me`, `friend list`, `group list`)
|
|
||||||
|
|
||||||
## Self (“me”)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot directory self --channel zalouser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Peers (contacts/users)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot directory peers list --channel zalouser
|
|
||||||
clawdbot directory peers list --channel zalouser --query "name"
|
|
||||||
clawdbot directory peers list --channel zalouser --limit 50
|
|
||||||
```
|
|
||||||
|
|
||||||
## Groups
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot directory groups list --channel zalouser
|
|
||||||
clawdbot directory groups list --channel zalouser --query "work"
|
|
||||||
clawdbot directory groups members --channel zalouser --group-id <id>
|
|
||||||
```
|
|
||||||
@@ -21,17 +21,3 @@ 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
|
|
||||||
|
|
||||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
|
||||||
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
|
||||||
|
|
||||||
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
|
||||||
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
|
||||||
```
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,5 @@ Fetch health from the running Gateway.
|
|||||||
```bash
|
```bash
|
||||||
clawdbot health
|
clawdbot health
|
||||||
clawdbot health --json
|
clawdbot health --json
|
||||||
clawdbot health --verbose
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `--verbose` runs live probes and prints per-account timings when multiple accounts are configured.
|
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
|
||||||
+9
-262
@@ -1,275 +1,22 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
|
summary: "CLI reference for `clawdbot hooks` (Gmail Pub/Sub + webhook helpers)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to manage agent hooks
|
- You want to wire Gmail Pub/Sub events into Clawdbot hooks
|
||||||
- You want to install or update hooks
|
- You want to run the gog watch service and renew loop
|
||||||
---
|
---
|
||||||
|
|
||||||
# `clawdbot hooks`
|
# `clawdbot hooks`
|
||||||
|
|
||||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
Webhook helpers and hook-based integrations.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Webhooks: [Webhook](/automation/webhook)
|
||||||
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
|
||||||
|
|
||||||
## List All Hooks
|
## Gmail
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot hooks list
|
clawdbot hooks gmail setup --account you@example.com
|
||||||
|
clawdbot hooks gmail run
|
||||||
```
|
```
|
||||||
|
|
||||||
List all discovered hooks from workspace, managed, and bundled directories.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--eligible`: Show only eligible hooks (requirements met)
|
|
||||||
- `--json`: Output as JSON
|
|
||||||
- `-v, --verbose`: Show detailed information including missing requirements
|
|
||||||
|
|
||||||
**Example output:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Hooks (3/3 ready)
|
|
||||||
|
|
||||||
Ready:
|
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
|
||||||
💾 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):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks list --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
Shows missing requirements for ineligible hooks.
|
|
||||||
|
|
||||||
**Example (JSON):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks list --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns structured JSON for programmatic use.
|
|
||||||
|
|
||||||
## Get Hook Information
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks info <name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Show detailed information about a specific hook.
|
|
||||||
|
|
||||||
**Arguments:**
|
|
||||||
- `<name>`: Hook name (e.g., `session-memory`)
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--json`: Output as JSON
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks info session-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```
|
|
||||||
💾 session-memory ✓ Ready
|
|
||||||
|
|
||||||
Save session context to memory when /new command is issued
|
|
||||||
|
|
||||||
Details:
|
|
||||||
Source: clawdbot-bundled
|
|
||||||
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
|
|
||||||
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
|
|
||||||
Homepage: https://docs.clawd.bot/hooks#session-memory
|
|
||||||
Events: command:new
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
Config: ✓ workspace.dir
|
|
||||||
```
|
|
||||||
|
|
||||||
## Check Hooks Eligibility
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks check
|
|
||||||
```
|
|
||||||
|
|
||||||
Show summary of hook eligibility status (how many are ready vs. not ready).
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--json`: Output as JSON
|
|
||||||
|
|
||||||
**Example output:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Hooks Status
|
|
||||||
|
|
||||||
Total hooks: 2
|
|
||||||
Ready: 2
|
|
||||||
Not ready: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Enable a Hook
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable <name>
|
|
||||||
```
|
|
||||||
|
|
||||||
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:**
|
|
||||||
- `<name>`: Hook name (e.g., `session-memory`)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable session-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```
|
|
||||||
✓ Enabled hook: 💾 session-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Checks if hook exists and is eligible
|
|
||||||
- Updates `hooks.internal.entries.<name>.enabled = true` in your config
|
|
||||||
- Saves config to disk
|
|
||||||
|
|
||||||
**After enabling:**
|
|
||||||
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
|
|
||||||
|
|
||||||
## Disable a Hook
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks disable <name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Disable a specific hook by updating your config.
|
|
||||||
|
|
||||||
**Arguments:**
|
|
||||||
- `<name>`: Hook name (e.g., `command-logger`)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks disable command-logger
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```
|
|
||||||
⏸ Disabled hook: 📝 command-logger
|
|
||||||
```
|
|
||||||
|
|
||||||
**After disabling:**
|
|
||||||
- Restart the gateway so hooks reload
|
|
||||||
|
|
||||||
## Install Hooks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks install <path-or-spec>
|
|
||||||
```
|
|
||||||
|
|
||||||
Install a hook pack from a local folder/archive or npm.
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
|
|
||||||
- Enables the installed hooks in `hooks.internal.entries.*`
|
|
||||||
- Records the install under `hooks.internal.installs`
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
|
|
||||||
|
|
||||||
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local directory
|
|
||||||
clawdbot hooks install ./my-hook-pack
|
|
||||||
|
|
||||||
# Local archive
|
|
||||||
clawdbot hooks install ./my-hook-pack.zip
|
|
||||||
|
|
||||||
# NPM package
|
|
||||||
clawdbot hooks install @clawdbot/my-hook-pack
|
|
||||||
|
|
||||||
# Link a local directory without copying
|
|
||||||
clawdbot hooks install -l ./my-hook-pack
|
|
||||||
```
|
|
||||||
|
|
||||||
## Update Hooks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks update <id>
|
|
||||||
clawdbot hooks update --all
|
|
||||||
```
|
|
||||||
|
|
||||||
Update installed hook packs (npm installs only).
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
- `--all`: Update all tracked hook packs
|
|
||||||
- `--dry-run`: Show what would change without writing
|
|
||||||
|
|
||||||
## Bundled Hooks
|
|
||||||
|
|
||||||
### session-memory
|
|
||||||
|
|
||||||
Saves session context to memory when you issue `/new`.
|
|
||||||
|
|
||||||
**Enable:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable session-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
|
||||||
|
|
||||||
**See:** [session-memory documentation](/hooks#session-memory)
|
|
||||||
|
|
||||||
### command-logger
|
|
||||||
|
|
||||||
Logs all command events to a centralized audit file.
|
|
||||||
|
|
||||||
**Enable:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable command-logger
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:** `~/.clawdbot/logs/commands.log`
|
|
||||||
|
|
||||||
**View logs:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recent commands
|
|
||||||
tail -n 20 ~/.clawdbot/logs/commands.log
|
|
||||||
|
|
||||||
# Pretty-print
|
|
||||||
cat ~/.clawdbot/logs/commands.log | jq .
|
|
||||||
|
|
||||||
# Filter by action
|
|
||||||
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
**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)
|
|
||||||
+25
-52
@@ -23,7 +23,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`message`](/cli/message)
|
- [`message`](/cli/message)
|
||||||
- [`agent`](/cli/agent)
|
- [`agent`](/cli/agent)
|
||||||
- [`agents`](/cli/agents)
|
- [`agents`](/cli/agents)
|
||||||
- [`acp`](/cli/acp)
|
|
||||||
- [`status`](/cli/status)
|
- [`status`](/cli/status)
|
||||||
- [`health`](/cli/health)
|
- [`health`](/cli/health)
|
||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
@@ -33,7 +32,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
- [`node`](/cli/node)
|
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -42,7 +40,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`dns`](/cli/dns)
|
- [`dns`](/cli/dns)
|
||||||
- [`docs`](/cli/docs)
|
- [`docs`](/cli/docs)
|
||||||
- [`hooks`](/cli/hooks)
|
- [`hooks`](/cli/hooks)
|
||||||
- [`webhooks`](/cli/webhooks)
|
|
||||||
- [`pairing`](/cli/pairing)
|
- [`pairing`](/cli/pairing)
|
||||||
- [`plugins`](/cli/plugins) (plugin commands)
|
- [`plugins`](/cli/plugins) (plugin commands)
|
||||||
- [`channels`](/cli/channels)
|
- [`channels`](/cli/channels)
|
||||||
@@ -127,7 +124,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
add
|
add
|
||||||
delete
|
delete
|
||||||
acp
|
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@@ -171,15 +167,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
node
|
status
|
||||||
start
|
describe
|
||||||
daemon
|
list
|
||||||
status
|
pending
|
||||||
install
|
approve
|
||||||
uninstall
|
reject
|
||||||
start
|
rename
|
||||||
stop
|
invoke
|
||||||
restart
|
run
|
||||||
|
notify
|
||||||
|
camera list|snap|clip
|
||||||
|
canvas snapshot|present|hide|navigate|eval
|
||||||
|
canvas a2ui push|reset
|
||||||
|
screen record
|
||||||
|
location get
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -210,14 +212,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
console
|
console
|
||||||
pdf
|
pdf
|
||||||
hooks
|
hooks
|
||||||
list
|
|
||||||
info
|
|
||||||
check
|
|
||||||
enable
|
|
||||||
disable
|
|
||||||
install
|
|
||||||
update
|
|
||||||
webhooks
|
|
||||||
gmail setup|run
|
gmail setup|run
|
||||||
pairing
|
pairing
|
||||||
list
|
list
|
||||||
@@ -289,7 +283,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|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`)
|
||||||
@@ -297,9 +291,7 @@ Options:
|
|||||||
- `--anthropic-api-key <key>`
|
- `--anthropic-api-key <key>`
|
||||||
- `--openai-api-key <key>`
|
- `--openai-api-key <key>`
|
||||||
- `--openrouter-api-key <key>`
|
- `--openrouter-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>`
|
||||||
@@ -421,12 +413,12 @@ Subcommands:
|
|||||||
- `pairing list <channel> [--json]`
|
- `pairing list <channel> [--json]`
|
||||||
- `pairing approve <channel> <code> [--notify]`
|
- `pairing approve <channel> <code> [--notify]`
|
||||||
|
|
||||||
### `webhooks gmail`
|
### `hooks gmail`
|
||||||
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||||
- `webhooks gmail run` (runtime overrides for the same flags)
|
- `hooks gmail run` (runtime overrides for the same flags)
|
||||||
|
|
||||||
### `dns setup`
|
### `dns setup`
|
||||||
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
|
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
|
||||||
@@ -453,8 +445,8 @@ Subcommands:
|
|||||||
- `message event <list|create>`
|
- `message event <list|create>`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `clawdbot message send --target +15555550123 --message "Hi"`
|
- `clawdbot message send --to +15555550123 --message "Hi"`
|
||||||
- `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
|
- `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
|
||||||
|
|
||||||
### `agent`
|
### `agent`
|
||||||
Run one agent turn via the Gateway (or `--local` embedded).
|
Run one agent turn via the Gateway (or `--local` embedded).
|
||||||
@@ -466,7 +458,7 @@ Options:
|
|||||||
- `--to <dest>` (for session key and optional delivery)
|
- `--to <dest>` (for session key and optional delivery)
|
||||||
- `--session-id <id>`
|
- `--session-id <id>`
|
||||||
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||||
- `--verbose <on|full|off>`
|
- `--verbose <on|off>`
|
||||||
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
|
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||||
- `--local`
|
- `--local`
|
||||||
- `--deliver`
|
- `--deliver`
|
||||||
@@ -503,11 +495,6 @@ Options:
|
|||||||
- `--force`
|
- `--force`
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
### `acp`
|
|
||||||
Run the ACP bridge that connects IDEs to the Gateway.
|
|
||||||
|
|
||||||
See [`acp`](/cli/acp) for full options and examples.
|
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
Show linked session health and recent recipients.
|
Show linked session health and recent recipients.
|
||||||
|
|
||||||
@@ -524,13 +511,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).
|
||||||
|
|
||||||
@@ -774,20 +761,6 @@ Subcommands:
|
|||||||
|
|
||||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||||
|
|
||||||
## Node host
|
|
||||||
|
|
||||||
`node` runs a **headless node host** or manages it as a background service. See
|
|
||||||
[`clawdbot node`](/cli/node).
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
- `node start --host <gateway-host> --port 18790`
|
|
||||||
- `node daemon status`
|
|
||||||
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
|
||||||
- `node daemon uninstall`
|
|
||||||
- `node daemon start`
|
|
||||||
- `node daemon stop`
|
|
||||||
- `node daemon restart`
|
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||||
@@ -804,7 +777,7 @@ Subcommands:
|
|||||||
- `nodes reject <requestId>`
|
- `nodes reject <requestId>`
|
||||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
|
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
||||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||||
|
|
||||||
Camera:
|
Camera:
|
||||||
|
|||||||
@@ -8,25 +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.
|
|
||||||
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
|
|
||||||
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
|
|
||||||
+31
-39
@@ -21,25 +21,19 @@ Channel selection:
|
|||||||
- If exactly one channel is configured, it becomes the default.
|
- If exactly one channel is configured, it becomes the default.
|
||||||
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||||
|
|
||||||
Target formats (`--target`):
|
Target formats (`--to`):
|
||||||
- WhatsApp: E.164 or group JID
|
- WhatsApp: E.164 or group JID
|
||||||
- Telegram: chat id or `@username`
|
- Telegram: chat id or `@username`
|
||||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are rejected)
|
||||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||||
|
|
||||||
Name lookup:
|
|
||||||
- For supported providers (Discord/Slack/etc), channel names like `Help` or `#help` are resolved via the directory cache.
|
|
||||||
- On cache miss, Clawdbot will attempt a live directory lookup when the provider supports it.
|
|
||||||
|
|
||||||
## Common flags
|
## Common flags
|
||||||
|
|
||||||
- `--channel <name>`
|
- `--channel <name>`
|
||||||
- `--account <id>`
|
- `--account <id>`
|
||||||
- `--target <dest>` (target channel or user for send/poll/read/etc)
|
|
||||||
- `--targets <name>` (repeat; broadcast only)
|
|
||||||
- `--json`
|
- `--json`
|
||||||
- `--dry-run`
|
- `--dry-run`
|
||||||
- `--verbose`
|
- `--verbose`
|
||||||
@@ -50,56 +44,61 @@ Name lookup:
|
|||||||
|
|
||||||
- `send`
|
- `send`
|
||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||||
- Required: `--target`, plus `--message` or `--media`
|
- Required: `--to`, plus `--message` or `--media`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
- Telegram only: `--buttons` (requires `"inlineButtons"` in `channels.telegram.capabilities` or `channels.telegram.accounts.<id>.capabilities`)
|
||||||
- Telegram only: `--thread-id` (forum topic id)
|
- Telegram only: `--thread-id` (forum topic id)
|
||||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||||
- WhatsApp only: `--gif-playback`
|
- WhatsApp only: `--gif-playback`
|
||||||
|
|
||||||
- `poll`
|
- `poll`
|
||||||
- Channels: WhatsApp/Discord/MS Teams
|
- Channels: WhatsApp/Discord/MS Teams
|
||||||
- Required: `--target`, `--poll-question`, `--poll-option` (repeat)
|
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
|
||||||
- Optional: `--poll-multi`
|
- Optional: `--poll-multi`
|
||||||
- Discord only: `--poll-duration-hours`, `--message`
|
- Discord only: `--poll-duration-hours`, `--message`
|
||||||
|
|
||||||
- `react`
|
- `react`
|
||||||
- Channels: Discord/Slack/Telegram/WhatsApp
|
- Channels: Discord/Slack/Telegram/WhatsApp
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--to` or `--channel-id`
|
||||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
|
||||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||||
- WhatsApp only: `--participant`, `--from-me`
|
- WhatsApp only: `--participant`, `--from-me`
|
||||||
|
|
||||||
- `reactions`
|
- `reactions`
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Slack
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--to` or `--channel-id`
|
||||||
- Optional: `--limit`
|
- Optional: `--limit`, `--channel-id`
|
||||||
|
|
||||||
- `read`
|
- `read`
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Slack
|
||||||
- Required: `--target`
|
- Required: `--to` or `--channel-id`
|
||||||
- Optional: `--limit`, `--before`, `--after`
|
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
|
||||||
- Discord only: `--around`
|
- Discord only: `--around`
|
||||||
|
|
||||||
- `edit`
|
- `edit`
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Slack
|
||||||
- Required: `--message-id`, `--message`, `--target`
|
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
|
||||||
|
- Optional: `--channel-id`
|
||||||
|
|
||||||
- `delete`
|
- `delete`
|
||||||
- Channels: Discord/Slack/Telegram
|
- Channels: Discord/Slack/Telegram
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--to` or `--channel-id`
|
||||||
|
- Optional: `--channel-id`
|
||||||
|
|
||||||
- `pin` / `unpin`
|
- `pin` / `unpin`
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Slack
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--to` or `--channel-id`
|
||||||
|
- Optional: `--channel-id`
|
||||||
|
|
||||||
- `pins` (list)
|
- `pins` (list)
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Slack
|
||||||
- Required: `--target`
|
- Required: `--to` or `--channel-id`
|
||||||
|
- Optional: `--channel-id`
|
||||||
|
|
||||||
- `permissions`
|
- `permissions`
|
||||||
- Channels: Discord
|
- Channels: Discord
|
||||||
- Required: `--target`
|
- Required: `--to` or `--channel-id`
|
||||||
|
- Optional: `--channel-id`
|
||||||
|
|
||||||
- `search`
|
- `search`
|
||||||
- Channels: Discord
|
- Channels: Discord
|
||||||
@@ -110,7 +109,7 @@ Name lookup:
|
|||||||
|
|
||||||
- `thread create`
|
- `thread create`
|
||||||
- Channels: Discord
|
- Channels: Discord
|
||||||
- Required: `--thread-name`, `--target` (channel id)
|
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
|
||||||
- Optional: `--message-id`, `--auto-archive-min`
|
- Optional: `--message-id`, `--auto-archive-min`
|
||||||
|
|
||||||
- `thread list`
|
- `thread list`
|
||||||
@@ -120,7 +119,7 @@ Name lookup:
|
|||||||
|
|
||||||
- `thread reply`
|
- `thread reply`
|
||||||
- Channels: Discord
|
- Channels: Discord
|
||||||
- Required: `--target` (thread id), `--message`
|
- Required: `--to` (thread id), `--message`
|
||||||
- Optional: `--media`, `--reply-to`
|
- Optional: `--media`, `--reply-to`
|
||||||
|
|
||||||
### Emojis
|
### Emojis
|
||||||
@@ -138,7 +137,7 @@ Name lookup:
|
|||||||
|
|
||||||
- `sticker send`
|
- `sticker send`
|
||||||
- Channels: Discord
|
- Channels: Discord
|
||||||
- Required: `--target`, `--sticker-id` (repeat)
|
- Required: `--to`, `--sticker-id` (repeat)
|
||||||
- Optional: `--message`
|
- Optional: `--message`
|
||||||
|
|
||||||
- `sticker upload`
|
- `sticker upload`
|
||||||
@@ -149,7 +148,7 @@ Name lookup:
|
|||||||
|
|
||||||
- `role info` (Discord): `--guild-id`
|
- `role info` (Discord): `--guild-id`
|
||||||
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
|
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
|
||||||
- `channel info` (Discord): `--target`
|
- `channel info` (Discord): `--channel-id`
|
||||||
- `channel list` (Discord): `--guild-id`
|
- `channel list` (Discord): `--guild-id`
|
||||||
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
|
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
|
||||||
- `voice status` (Discord): `--guild-id`, `--user-id`
|
- `voice status` (Discord): `--guild-id`, `--user-id`
|
||||||
@@ -167,25 +166,18 @@ Name lookup:
|
|||||||
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
|
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
|
||||||
- `timeout` also supports `--reason`
|
- `timeout` also supports `--reason`
|
||||||
|
|
||||||
### Broadcast
|
|
||||||
|
|
||||||
- `broadcast`
|
|
||||||
- Channels: any configured channel; use `--channel all` to target all providers
|
|
||||||
- Required: `--targets` (repeat)
|
|
||||||
- Optional: `--message`, `--media`, `--dry-run`
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Send a Discord reply:
|
Send a Discord reply:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel discord \
|
clawdbot message send --channel discord \
|
||||||
--target channel:123 --message "hi" --reply-to 456
|
--to channel:123 --message "hi" --reply-to 456
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a Discord poll:
|
Create a Discord poll:
|
||||||
```
|
```
|
||||||
clawdbot message poll --channel discord \
|
clawdbot message poll --channel discord \
|
||||||
--target channel:123 \
|
--to channel:123 \
|
||||||
--poll-question "Snack?" \
|
--poll-question "Snack?" \
|
||||||
--poll-option Pizza --poll-option Sushi \
|
--poll-option Pizza --poll-option Sushi \
|
||||||
--poll-multi --poll-duration-hours 48
|
--poll-multi --poll-duration-hours 48
|
||||||
@@ -194,13 +186,13 @@ clawdbot message poll --channel discord \
|
|||||||
Send a Teams proactive message:
|
Send a Teams proactive message:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel msteams \
|
clawdbot message send --channel msteams \
|
||||||
--target conversation:19:abc@thread.tacv2 --message "hi"
|
--to conversation:19:abc@thread.tacv2 --message "hi"
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a Teams poll:
|
Create a Teams poll:
|
||||||
```
|
```
|
||||||
clawdbot message poll --channel msteams \
|
clawdbot message poll --channel msteams \
|
||||||
--target conversation:19:abc@thread.tacv2 \
|
--to conversation:19:abc@thread.tacv2 \
|
||||||
--poll-question "Lunch?" \
|
--poll-question "Lunch?" \
|
||||||
--poll-option Pizza --poll-option Sushi
|
--poll-option Pizza --poll-option Sushi
|
||||||
```
|
```
|
||||||
@@ -208,11 +200,11 @@ clawdbot message poll --channel msteams \
|
|||||||
React in Slack:
|
React in Slack:
|
||||||
```
|
```
|
||||||
clawdbot message react --channel slack \
|
clawdbot message react --channel slack \
|
||||||
--target C123 --message-id 456 --emoji "✅"
|
--to C123 --message-id 456 --emoji "✅"
|
||||||
```
|
```
|
||||||
|
|
||||||
Send Telegram inline buttons:
|
Send Telegram inline buttons:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
clawdbot message send --channel telegram --to @mychat --message "Choose:" \
|
||||||
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||||
```
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot node` (headless node host)"
|
|
||||||
read_when:
|
|
||||||
- Running the headless node host
|
|
||||||
- Pairing a non-macOS node for system.run
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot node`
|
|
||||||
|
|
||||||
Run a **headless node host** that connects to the Gateway bridge and exposes
|
|
||||||
`system.run` / `system.which` on this machine.
|
|
||||||
|
|
||||||
## Why use a node host?
|
|
||||||
|
|
||||||
Use a node host when you want agents to **run commands on other machines** in your
|
|
||||||
network without installing a full macOS companion app there.
|
|
||||||
|
|
||||||
Common use cases:
|
|
||||||
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
|
|
||||||
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
|
|
||||||
- Provide a lightweight, headless execution target for automation or CI nodes.
|
|
||||||
|
|
||||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
|
||||||
node host, so you can keep command access scoped and explicit.
|
|
||||||
|
|
||||||
## Start (foreground)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node start --host <gateway-host> --port 18790
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
|
||||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
|
||||||
- `--tls`: Use TLS for the bridge connection
|
|
||||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
|
||||||
- `--node-id <id>`: Override node id (clears pairing token)
|
|
||||||
- `--display-name <name>`: Override the node display name
|
|
||||||
|
|
||||||
## Daemon (background service)
|
|
||||||
|
|
||||||
Install a headless node host as a user service.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node daemon install --host <gateway-host> --port 18790
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
|
||||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
|
||||||
- `--tls`: Use TLS for the bridge connection
|
|
||||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
|
||||||
- `--node-id <id>`: Override node id (clears pairing token)
|
|
||||||
- `--display-name <name>`: Override the node display name
|
|
||||||
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
|
||||||
- `--force`: Reinstall/overwrite if already installed
|
|
||||||
|
|
||||||
Manage the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node daemon status
|
|
||||||
clawdbot node daemon start
|
|
||||||
clawdbot node daemon stop
|
|
||||||
clawdbot node daemon restart
|
|
||||||
clawdbot node daemon uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pairing
|
|
||||||
|
|
||||||
The first connection creates a pending node pair request on the Gateway.
|
|
||||||
Approve it via:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot nodes pending
|
|
||||||
clawdbot nodes approve <requestId>
|
|
||||||
```
|
|
||||||
|
|
||||||
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
|
||||||
|
|
||||||
## Exec approvals
|
|
||||||
|
|
||||||
`system.run` is gated by local exec approvals:
|
|
||||||
|
|
||||||
- `~/.clawdbot/exec-approvals.json`
|
|
||||||
- [Exec approvals](/tools/exec-approvals)
|
|
||||||
+1
-12
@@ -25,25 +25,14 @@ 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
|
||||||
clawdbot plugins install <path-or-spec>
|
clawdbot plugins install <npm-spec>
|
||||||
```
|
```
|
||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
|
|
||||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
|
||||||
|
|
||||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot plugins install -l ./my-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -16,7 +16,3 @@ clawdbot status --deep
|
|||||||
clawdbot status --usage
|
clawdbot status --usage
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
|
||||||
Loaded 100 of 1336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user